From 311b0269b4eb9839fa63f80c8d7a58f32b8138a0 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 18 Nov 2021 13:16:36 +0000 Subject: Add latest changes from gitlab-org/gitlab@14-5-stable-ee --- spec/commands/sidekiq_cluster/cli_spec.rb | 336 +++++ .../admin/integrations_controller_spec.rb | 2 +- spec/controllers/admin/runners_controller_spec.rb | 4 +- spec/controllers/application_controller_spec.rb | 32 +- spec/controllers/concerns/group_tree_spec.rb | 8 - .../controllers/concerns/import_url_params_spec.rb | 2 +- spec/controllers/concerns/renders_commits_spec.rb | 6 + spec/controllers/confirmations_controller_spec.rb | 41 + .../controllers/dashboard/todos_controller_spec.rb | 2 +- .../explore/projects_controller_spec.rb | 22 + ...endency_proxy_for_containers_controller_spec.rb | 158 ++- .../settings/integrations_controller_spec.rb | 6 +- spec/controllers/groups_controller_spec.rb | 10 + .../import/bitbucket_controller_spec.rb | 24 + .../jira_connect/app_descriptor_controller_spec.rb | 12 - .../jira_connect/events_controller_spec.rb | 75 -- .../oauth/authorizations_controller_spec.rb | 7 +- spec/controllers/passwords_controller_spec.rb | 43 + .../profiles/accounts_controller_spec.rb | 2 +- .../profiles/two_factor_auths_controller_spec.rb | 28 +- spec/controllers/profiles_controller_spec.rb | 10 + .../alerting/notifications_controller_spec.rb | 10 +- .../cycle_analytics/stages_controller_spec.rb | 1 + .../projects/branches_controller_spec.rb | 6 +- .../projects/ci/pipeline_editor_controller_spec.rb | 20 +- .../projects/commits_controller_spec.rb | 23 + spec/controllers/projects/hooks_controller_spec.rb | 2 +- .../controllers/projects/issues_controller_spec.rb | 34 +- spec/controllers/projects/jobs_controller_spec.rb | 83 +- .../merge_requests/diffs_controller_spec.rb | 6 +- .../projects/merge_requests_controller_spec.rb | 26 +- spec/controllers/projects/notes_controller_spec.rb | 29 + .../projects/pipelines_controller_spec.rb | 2 +- .../projects/prometheus/alerts_controller_spec.rb | 11 +- .../projects/releases_controller_spec.rb | 13 +- .../projects/services_controller_spec.rb | 6 +- spec/controllers/projects/tags_controller_spec.rb | 2 +- spec/controllers/projects_controller_spec.rb | 31 +- .../registrations/welcome_controller_spec.rb | 10 + spec/controllers/registrations_controller_spec.rb | 5 +- spec/db/schema_spec.rb | 9 +- ...tinuous_onboarding_link_urls_experiment_spec.rb | 53 + .../empty_repo_upload_experiment_spec.rb | 49 - .../cycle_analytics/issue_stage_events.rb | 13 + .../cycle_analytics/merge_request_stage_events.rb | 13 + spec/factories/authentication_event.rb | 8 + spec/factories/ci/builds.rb | 8 +- spec/factories/ci/job_artifacts.rb | 11 + spec/factories/ci/pipelines.rb | 8 + spec/factories/ci/reports/security/findings.rb | 4 +- spec/factories/ci/runner_namespaces.rb | 9 +- spec/factories/ci/runners.rb | 9 +- .../issue_customer_relations_contacts.rb | 27 + spec/factories/design_management/designs.rb | 2 +- spec/factories/error_tracking/error_event.rb | 4 + .../gitlab/database/reindexing/queued_action.rb | 10 + spec/factories/group_members.rb | 13 + spec/factories/integrations.rb | 11 + spec/factories/member_tasks.rb | 9 + spec/factories/namespaces/project_namespaces.rb | 2 +- .../factories/operations/feature_flags/strategy.rb | 32 + spec/factories/packages/helm/file_metadatum.rb | 6 +- spec/factories/packages/npm/metadata.rb | 18 + spec/factories/project_members.rb | 10 + spec/factories/user_highest_roles.rb | 10 +- spec/factories/users/credit_card_validations.rb | 7 +- spec/factories_spec.rb | 4 + spec/features/admin/admin_appearance_spec.rb | 2 +- spec/features/admin/admin_deploy_keys_spec.rb | 28 +- .../admin/admin_disables_two_factor_spec.rb | 1 + spec/features/admin/admin_groups_spec.rb | 1 + spec/features/admin/admin_hooks_spec.rb | 1 + spec/features/admin/admin_labels_spec.rb | 1 + .../admin/admin_manage_applications_spec.rb | 1 + spec/features/admin/admin_runners_spec.rb | 113 +- .../admin/admin_sees_project_statistics_spec.rb | 2 +- spec/features/admin/admin_settings_spec.rb | 8 +- .../admin/admin_users_impersonation_tokens_spec.rb | 1 + .../admin/admin_uses_repository_checks_spec.rb | 1 + spec/features/admin/clusters/eks_spec.rb | 2 +- spec/features/admin/users/user_spec.rb | 1 + spec/features/admin/users/users_spec.rb | 7 +- .../alert_management/alert_management_list_spec.rb | 24 - spec/features/boards/boards_spec.rb | 1 + spec/features/clusters/create_agent_spec.rb | 44 + spec/features/contextual_sidebar_spec.rb | 109 +- spec/features/cycle_analytics_spec.rb | 24 +- spec/features/dashboard/projects_spec.rb | 8 +- spec/features/explore/topics_spec.rb | 25 + spec/features/graphql_known_operations_spec.rb | 29 + spec/features/groups/clusters/eks_spec.rb | 2 +- spec/features/groups/clusters/user_spec.rb | 4 +- spec/features/groups/dependency_proxy_spec.rb | 9 +- spec/features/groups/issues_spec.rb | 47 +- spec/features/groups/labels/subscription_spec.rb | 4 +- spec/features/groups/members/leave_group_spec.rb | 1 + spec/features/groups/navbar_spec.rb | 17 + spec/features/groups/packages_spec.rb | 4 - .../groups/settings/manage_applications_spec.rb | 1 + .../incidents/user_creates_new_incident_spec.rb | 55 +- .../features/incidents/user_views_incident_spec.rb | 28 +- spec/features/invites_spec.rb | 14 + .../internal_references_spec.rb | 15 +- spec/features/issue_rebalancing_spec.rb | 65 + spec/features/issues/form_spec.rb | 72 +- spec/features/issues/issue_detail_spec.rb | 64 +- spec/features/issues/user_creates_issue_spec.rb | 58 +- spec/features/issues/user_edits_issue_spec.rb | 5 +- .../issues/user_toggles_subscription_spec.rb | 2 +- .../issues/user_uses_quick_actions_spec.rb | 1 + spec/features/jira_connect/subscriptions_spec.rb | 4 +- spec/features/merge_request/user_approves_spec.rb | 2 +- .../merge_request/user_assigns_themselves_spec.rb | 2 +- .../merge_request/user_comments_on_diff_spec.rb | 1 + .../user_customizes_merge_commit_message_spec.rb | 26 +- .../user_merges_when_pipeline_succeeds_spec.rb | 4 +- .../merge_request/user_posts_diff_notes_spec.rb | 7 +- .../merge_request/user_posts_notes_spec.rb | 2 + .../user_sees_avatar_on_diff_notes_spec.rb | 1 + .../user_sees_deployment_widget_spec.rb | 1 + .../merge_request/user_sees_merge_widget_spec.rb | 2 +- .../user_sees_suggest_pipeline_spec.rb | 40 +- spec/features/oauth_login_spec.rb | 2 +- spec/features/profile_spec.rb | 2 + spec/features/profiles/active_sessions_spec.rb | 4 + spec/features/profiles/emails_spec.rb | 17 +- spec/features/profiles/oauth_applications_spec.rb | 1 + .../profiles/personal_access_tokens_spec.rb | 1 + spec/features/profiles/two_factor_auths_spec.rb | 25 +- .../profiles/user_manages_applications_spec.rb | 1 + spec/features/profiles/user_manages_emails_spec.rb | 15 +- spec/features/profiles/user_visits_profile_spec.rb | 8 + spec/features/project_variables_spec.rb | 2 +- .../projects/branches/user_deletes_branch_spec.rb | 1 + spec/features/projects/branches_spec.rb | 1 + spec/features/projects/cluster_agents_spec.rb | 53 + spec/features/projects/clusters/eks_spec.rb | 3 +- spec/features/projects/clusters/gcp_spec.rb | 22 +- spec/features/projects/clusters/user_spec.rb | 6 +- spec/features/projects/clusters_spec.rb | 26 +- .../commit/comments/user_deletes_comments_spec.rb | 1 + .../commit/user_comments_on_commit_spec.rb | 2 + .../confluence/user_views_confluence_page_spec.rb | 3 + .../projects/environments/environment_spec.rb | 36 +- .../projects/environments/environments_spec.rb | 3 + .../projects/import_export/import_file_spec.rb | 8 +- .../projects/infrastructure_registry_spec.rb | 2 +- .../user_uses_inherited_settings_spec.rb | 2 +- .../projects/jobs/user_browses_job_spec.rb | 27 +- ...user_triggers_manual_job_with_variables_spec.rb | 34 + .../projects/members/member_leaves_project_spec.rb | 1 + .../projects/members/user_requests_access_spec.rb | 1 + spec/features/projects/new_project_spec.rb | 36 +- spec/features/projects/packages_spec.rb | 4 - .../projects/pages/user_adds_domain_spec.rb | 2 + .../pages/user_edits_lets_encrypt_settings_spec.rb | 1 + .../projects/pages/user_edits_settings_spec.rb | 1 + spec/features/projects/pipeline_schedules_spec.rb | 1 + spec/features/projects/pipelines/pipelines_spec.rb | 3 +- .../projects/releases/user_views_releases_spec.rb | 4 +- .../projects/settings/access_tokens_spec.rb | 1 + .../projects/settings/packages_settings_spec.rb | 4 +- .../projects/settings/service_desk_setting_spec.rb | 2 +- .../settings/user_searches_in_settings_spec.rb | 1 + .../projects/settings/user_tags_project_spec.rb | 26 +- spec/features/projects/show/no_password_spec.rb | 11 +- .../projects/show/user_uploads_files_spec.rb | 28 +- .../user_changes_project_visibility_spec.rb | 2 +- .../features/projects/user_creates_project_spec.rb | 8 +- spec/features/projects_spec.rb | 12 +- spec/features/signed_commits_spec.rb | 16 +- .../snippets/notes_on_personal_snippets_spec.rb | 1 + .../features/snippets/user_creates_snippet_spec.rb | 1 + spec/features/topic_show_spec.rb | 48 + spec/features/triggers_spec.rb | 1 + spec/features/users/confirmation_spec.rb | 30 + spec/features/users/login_spec.rb | 6 +- spec/features/users/password_spec.rb | 30 + spec/features/users/terms_spec.rb | 2 +- spec/finders/autocomplete/routes_finder_spec.rb | 57 + spec/finders/branches_finder_spec.rb | 8 +- .../ci/pipelines_for_merge_request_finder_spec.rb | 150 +-- .../clusters/agent_authorizations_finder_spec.rb | 124 ++ .../environments_by_deployments_finder_spec.rb | 14 +- spec/finders/members_finder_spec.rb | 8 - spec/finders/tags_finder_spec.rb | 81 +- .../schemas/analytics/cycle_analytics/summary.json | 3 + .../schemas/graphql/packages/package_details.json | 4 +- .../api/schemas/public_api/v4/deploy_key.json | 25 + .../api/schemas/public_api/v4/deploy_keys.json | 4 + .../v4/packages/npm_package_version.json | 12 +- spec/fixtures/bulk_imports/gz/milestones.ndjson.gz | Bin 402 -> 0 bytes spec/fixtures/bulk_imports/milestones.ndjson | 5 - spec/fixtures/emails/service_desk_all_quoted.eml | 22 + .../emails/service_desk_custom_address_no_key.eml | 27 + spec/fixtures/emails/service_desk_forwarded.eml | 4 +- spec/fixtures/error_tracking/browser_event.json | 1 + spec/fixtures/error_tracking/go_parsed_event.json | 1 + spec/fixtures/error_tracking/python_event.json | 1 + .../lightweight_project_export.tar.gz | Bin 3647 -> 3758 bytes .../lib/gitlab/import_export/complex/project.json | 308 ----- .../complex/tree/project/merge_requests.ndjson | 16 +- spec/fixtures/packages/npm/payload.json | 3 +- .../npm/payload_with_duplicated_packages.json | 3 +- spec/fixtures/scripts/test_report.json | 36 + .../frontend/__helpers__/experimentation_helper.js | 29 +- spec/frontend/__mocks__/@gitlab/ui.js | 4 +- .../components/devops_score_callout_spec.js | 4 +- .../devops_score/components/devops_score_spec.js | 4 +- .../admin/deploy_keys/components/table_spec.js | 47 + spec/frontend/alert_handler_spec.js | 12 +- .../components/alert_management_table_spec.js | 18 - .../components/alerts_settings_form_spec.js | 19 +- .../components/service_ping_disabled_spec.js | 59 - .../components/service_ping_disabled_spec.js | 59 + .../components/manage_two_factor_form_spec.js | 150 ++- .../components/preview_dropdown_spec.js | 6 +- spec/frontend/behaviors/gl_emoji_spec.js | 6 +- .../__snapshots__/blob_header_spec.js.snap | 14 +- spec/frontend/blob/components/blob_header_spec.js | 2 + .../blob/components/table_contents_spec.js | 22 +- spec/frontend/boards/components/board_card_spec.js | 4 +- .../components/board_filtered_search_spec.js | 33 +- spec/frontend/boards/components/board_form_spec.js | 18 +- .../boards/components/boards_selector_spec.js | 251 ++-- .../components/issue_board_filtered_search_spec.js | 57 +- .../boards/components/new_board_button_spec.js | 75 ++ .../sidebar/board_sidebar_labels_select_spec.js | 1 + .../sidebar/board_sidebar_subscription_spec.js | 11 +- spec/frontend/boards/mock_data.js | 87 +- spec/frontend/boards/stores/actions_spec.js | 72 +- spec/frontend/chronic_duration_spec.js | 354 +++++ .../clusters/agents/components/show_spec.js | 63 +- .../components/remove_cluster_confirmation_spec.js | 45 +- .../components/agent_empty_state_spec.js | 16 +- .../clusters_list/components/agent_table_spec.js | 9 +- .../clusters_list/components/agents_spec.js | 40 +- .../components/clusters_actions_spec.js | 55 + .../components/clusters_empty_state_spec.js | 104 ++ .../components/clusters_main_view_spec.js | 82 ++ .../clusters_list/components/clusters_spec.js | 60 +- .../components/clusters_view_all_spec.js | 243 ++++ .../components/install_agent_modal_spec.js | 38 +- spec/frontend/clusters_list/mocks/apollo.js | 47 +- .../frontend/clusters_list/store/mutations_spec.js | 10 +- .../commit/pipelines/pipelines_table_spec.js | 18 +- spec/frontend/confirm_modal_spec.js | 4 +- .../components/content_editor_alert_spec.js | 60 + .../components/content_editor_error_spec.js | 54 - .../components/content_editor_spec.js | 6 +- .../components/wrappers/table_cell_base_spec.js | 8 +- .../components/wrappers/table_cell_body_spec.js | 8 +- .../components/wrappers/table_cell_header_spec.js | 8 +- .../content_editor/extensions/attachment_spec.js | 20 +- .../content_editor/extensions/blockquote_spec.js | 46 +- .../content_editor/extensions/emoji_spec.js | 10 +- .../content_editor/extensions/frontmatter_spec.js | 30 + .../extensions/horizontal_rule_spec.js | 49 +- .../content_editor/extensions/inline_diff_spec.js | 60 +- .../content_editor/extensions/link_spec.js | 91 +- .../content_editor/extensions/math_inline_spec.js | 11 +- .../extensions/table_of_contents_spec.js | 32 +- .../content_editor/extensions/table_spec.js | 102 ++ .../content_editor/extensions/word_break_spec.js | 35 + .../services/markdown_serializer_spec.js | 4 - .../track_input_rules_and_shortcuts_spec.js | 11 +- spec/frontend/content_editor/test_utils.js | 23 + .../frontend/create_merge_request_dropdown_spec.js | 5 +- spec/frontend/crm/contacts_root_spec.js | 60 + spec/frontend/crm/mock_data.js | 81 ++ spec/frontend/crm/organizations_root_spec.js | 60 + .../components/custom_metrics_form_fields_spec.js | 10 +- .../cycle_analytics/metric_popover_spec.js | 102 ++ spec/frontend/cycle_analytics/mock_data.js | 41 +- .../frontend/cycle_analytics/store/actions_spec.js | 144 ++- .../cycle_analytics/store/mutations_spec.js | 1 + spec/frontend/cycle_analytics/utils_spec.js | 96 +- .../cycle_analytics/value_stream_metrics_spec.js | 58 +- spec/frontend/delete_label_modal_spec.js | 4 +- spec/frontend/deploy_keys/components/key_spec.js | 10 +- .../deploy_keys/components/keys_panel_spec.js | 2 +- spec/frontend/deprecated_jquery_dropdown_spec.js | 6 +- .../design_management/components/list/item_spec.js | 2 +- .../frontend/design_management/pages/index_spec.js | 14 + spec/frontend/diffs/components/app_spec.js | 34 +- .../diffs/components/diff_discussions_spec.js | 4 + .../diffs/components/diff_file_header_spec.js | 23 +- .../diffs/components/diff_line_note_form_spec.js | 89 +- spec/frontend/diffs/components/tree_list_spec.js | 4 +- spec/frontend/diffs/store/actions_spec.js | 51 +- spec/frontend/diffs/store/mutations_spec.js | 24 +- spec/frontend/diffs/utils/diff_line_spec.js | 30 + spec/frontend/diffs/utils/discussions_spec.js | 133 ++ spec/frontend/diffs/utils/file_reviews_spec.js | 24 +- spec/frontend/dropzone_input_spec.js | 19 + spec/frontend/editor/helpers.js | 53 + .../editor/source_editor_extension_base_spec.js | 68 +- .../editor/source_editor_extension_spec.js | 65 + .../frontend/editor/source_editor_instance_spec.js | 387 ++++++ .../frontend/editor/source_editor_yaml_ext_spec.js | 449 +++++++ spec/frontend/environments/graphql/mock_data.js | 530 ++++++++ .../environments/graphql/resolvers_spec.js | 91 ++ .../environments/new_environment_folder_spec.js | 74 ++ .../environments/new_environments_app_spec.js | 50 + spec/frontend/experimentation/utils_spec.js | 198 ++- .../configure_feature_flags_modal_spec.js | 13 +- spec/frontend/filterable_list_spec.js | 5 +- spec/frontend/fixtures/api_markdown.yml | 332 ++--- spec/frontend/fixtures/projects.rb | 26 + spec/frontend/flash_spec.js | 11 + spec/frontend/gfm_auto_complete_spec.js | 12 +- spec/frontend/google_cloud/components/app_spec.js | 66 + .../components/incubation_banner_spec.js | 60 + .../components/service_accounts_spec.js | 79 ++ spec/frontend/graphql_shared/utils_spec.js | 4 + .../components/shared_runners_form_spec.js | 47 +- .../pipelines/__snapshots__/list_spec.js.snap | 2 +- .../components/shared/commit_message_field_spec.js | 149 +++ spec/frontend/ide/stores/mutations_spec.js | 4 +- .../components/group_dropdown_spec.js | 18 +- .../components/import_actions_cell_spec.js | 33 +- .../components/import_source_cell_spec.js | 27 +- .../import_groups/components/import_table_spec.js | 235 ++-- .../components/import_target_cell_spec.js | 96 +- .../import_groups/graphql/client_factory_spec.js | 442 ++----- .../import_groups/graphql/fixtures.js | 42 +- .../graphql/services/local_storage_cache_spec.js | 61 + .../graphql/services/source_groups_manager_spec.js | 64 - .../graphql/services/status_poller_spec.js | 102 -- .../import_groups/services/status_poller_spec.js | 97 ++ .../incidents/components/incidents_list_spec.js | 26 +- .../edit/components/dynamic_field_spec.js | 229 ++-- .../edit/components/jira_issues_fields_spec.js | 87 +- .../integrations/integration_settings_form_spec.js | 303 +++-- .../invite_members/components/confetti_spec.js | 28 + .../components/invite_members_modal_spec.js | 210 ++- .../components/invite_members_trigger_spec.js | 35 +- .../issuable/components/csv_import_modal_spec.js | 8 +- spec/frontend/issue_show/components/app_spec.js | 38 +- .../issue_show/components/description_spec.js | 22 + .../issue_show/components/fields/type_spec.js | 26 +- .../issues_list/components/issues_list_app_spec.js | 2 + .../components/new_issue_dropdown_spec.js | 6 +- spec/frontend/issues_list/mock_data.js | 86 +- spec/frontend/issues_list/utils_spec.js | 8 +- .../components/add_namespace_button_spec.js | 44 + .../add_namespace_modal_spec.js | 36 + .../add_namespace_modal/groups_list_item_spec.js | 112 ++ .../add_namespace_modal/groups_list_spec.js | 303 +++++ .../subscriptions/components/app_spec.js | 176 +-- .../components/groups_list_item_spec.js | 112 -- .../subscriptions/components/groups_list_spec.js | 303 ----- .../components/sign_in_button_spec.js | 48 + .../components/subscriptions_list_spec.js | 50 +- .../jira_connect/subscriptions/index_spec.js | 36 +- .../jira_connect/subscriptions/utils_spec.js | 22 + .../jobs/components/manual_variables_form_spec.js | 152 ++- ...s_network_errors_during_navigation_link_spec.js | 150 +-- spec/frontend/lib/utils/common_utils_spec.js | 23 + .../confirm_via_gl_modal/confirm_modal_spec.js | 59 + spec/frontend/lib/utils/datetime_utility_spec.js | 10 +- spec/frontend/lib/utils/file_upload_spec.js | 28 +- spec/frontend/lib/utils/text_markdown_spec.js | 4 +- spec/frontend/lib/utils/url_utility_spec.js | 8 + spec/frontend/members/mock_data.js | 2 +- .../__snapshots__/alert_widget_spec.js.snap | 43 - spec/frontend/monitoring/alert_widget_spec.js | 423 ------ .../__snapshots__/dashboard_template_spec.js.snap | 2 - .../components/alert_widget_form_spec.js | 242 ---- .../monitoring/components/charts/anomaly_spec.js | 4 - .../components/charts/time_series_spec.js | 1 - .../components/dashboard_panel_builder_spec.js | 1 - .../monitoring/components/dashboard_panel_spec.js | 106 -- .../monitoring/components/dashboard_spec.js | 33 +- .../components/dashboard_url_time_spec.js | 1 - .../monitoring/components/links_section_spec.js | 10 +- .../components/variables/text_field_spec.js | 4 +- .../monitoring/pages/dashboard_page_spec.js | 4 +- spec/frontend/monitoring/router_spec.js | 3 - .../notes/components/discussion_counter_spec.js | 6 +- .../notes/components/discussion_notes_spec.js | 4 + .../components/multiline_comment_form_spec.js | 12 - spec/frontend/notes/components/note_body_spec.js | 1 - spec/frontend/notes/components/note_form_spec.js | 2 + .../notes/components/noteable_discussion_spec.js | 10 + spec/frontend/notes/components/notes_app_spec.js | 59 + .../notes/mixins/discussion_navigation_spec.js | 61 + spec/frontend/notes/stores/actions_spec.js | 91 +- spec/frontend/notes/stores/mutation_spec.js | 10 +- .../__snapshots__/packages_list_app_spec.js.snap | 6 +- .../list/components/packages_list_app_spec.js | 45 +- .../list/components/packages_search_spec.js | 128 -- .../list/components/packages_title_spec.js | 71 - .../components/tokens/package_type_token_spec.js | 48 - .../__snapshots__/registry_breadcrumb_spec.js.snap | 84 ++ .../explorer/components/delete_button_spec.js | 73 ++ .../explorer/components/delete_image_spec.js | 152 +++ .../__snapshots__/tags_loader_spec.js.snap | 61 + .../components/details_page/delete_alert_spec.js | 116 ++ .../components/details_page/delete_modal_spec.js | 152 +++ .../components/details_page/details_header_spec.js | 304 +++++ .../components/details_page/empty_state_spec.js | 54 + .../details_page/partial_cleanup_alert_spec.js | 74 ++ .../components/details_page/status_alert_spec.js | 57 + .../components/details_page/tags_list_row_spec.js | 382 ++++++ .../components/details_page/tags_list_spec.js | 314 +++++ .../components/details_page/tags_loader_spec.js | 45 + .../__snapshots__/group_empty_state_spec.js.snap | 15 + .../__snapshots__/project_empty_state_spec.js.snap | 83 ++ .../components/list_page/cleanup_status_spec.js | 87 ++ .../components/list_page/cli_commands_spec.js | 94 ++ .../components/list_page/group_empty_state_spec.js | 37 + .../components/list_page/image_list_row_spec.js | 223 ++++ .../components/list_page/image_list_spec.js | 88 ++ .../list_page/project_empty_state_spec.js | 45 + .../components/list_page/registry_header_spec.js | 135 ++ .../components/registry_breadcrumb_spec.js | 78 ++ .../container_registry/explorer/mock_data.js | 269 ++++ .../explorer/pages/details_spec.js | 521 ++++++++ .../explorer/pages/index_spec.js | 24 + .../container_registry/explorer/pages/list_spec.js | 597 +++++++++ .../container_registry/explorer/stubs.js | 45 + .../dependency_proxy/app_spec.js | 99 +- .../components/manifest_list_spec.js | 84 ++ .../components/manifest_row_spec.js | 59 + .../dependency_proxy/mock_data.js | 21 +- .../__snapshots__/package_title_spec.js.snap | 16 +- .../details/__snapshots__/version_row_spec.js.snap | 1 + .../components/details/app_spec.js | 53 +- .../details/installations_commands_spec.js | 14 +- .../components/functional/delete_package_spec.js | 160 +++ .../components/list/__snapshots__/app_spec.js.snap | 57 + .../package_registry/components/list/app_spec.js | 168 ++- .../components/list/packages_list_spec.js | 244 ++-- .../components/list/packages_search_spec.js | 2 +- .../components/list/packages_title_spec.js | 5 +- .../list/tokens/package_type_token_spec.js | 8 +- .../package_registry/mock_data.js | 17 +- .../components/expiration_dropdown_spec.js | 2 +- .../settings/components/expiration_input_spec.js | 2 +- .../components/expiration_run_text_spec.js | 2 +- .../settings/components/expiration_toggle_spec.js | 2 +- .../settings/components/settings_form_spec.js | 2 +- .../packages_and_registries/shared/mocks.js | 3 + .../packages_and_registries/shared/stubs.js | 31 + .../projects/components/namespace_select_spec.js | 8 +- .../pages/dashboard/todos/index/todos_spec.js | 4 +- .../__snapshots__/learn_gitlab_spec.js.snap | 7 + .../learn_gitlab/components/learn_gitlab_spec.js | 33 +- .../shared/wikis/components/wiki_form_spec.js | 26 +- .../components/commit/commit_form_spec.js | 19 + .../components/commit/commit_section_spec.js | 16 +- .../drawer/pipeline_editor_drawer_spec.js | 37 +- .../components/file-nav/branch_switcher_spec.js | 38 +- .../components/header/pipeline_status_spec.js | 39 +- .../header/pipline_editor_mini_graph_spec.js | 72 +- .../components/pipeline_editor_tabs_spec.js | 132 +- .../components/ui/pipeline_editor_messages_spec.js | 2 + .../components/walkthrough_popover_spec.js | 29 + spec/frontend/pipeline_editor/mock_data.js | 82 +- .../pipeline_editor/pipeline_editor_app_spec.js | 117 +- .../pipeline_editor/pipeline_editor_home_spec.js | 106 +- spec/frontend/pipelines/empty_state_spec.js | 2 +- .../graph/graph_component_wrapper_spec.js | 2 +- .../pipelines/graph/graph_view_selector_spec.js | 2 +- .../frontend/pipelines/pipelines_artifacts_spec.js | 83 +- spec/frontend/pipelines/pipelines_spec.js | 6 +- spec/frontend/pipelines/pipelines_table_spec.js | 6 +- .../projects/commit/components/form_modal_spec.js | 15 + .../commits/components/author_select_spec.js | 2 +- .../project_delete_button_spec.js.snap | 41 +- .../components/project_delete_button_spec.js | 12 +- .../projects/details/upload_button_spec.js | 7 - .../new/components/new_project_url_select_spec.js | 39 +- .../pipelines/charts/components/app_spec.js | 24 +- .../projects/projects_filterable_list_spec.js | 5 +- .../components/topics_token_selector_spec.js | 98 ++ .../settings_service_desk/components/mock_data.js | 8 + .../components/service_desk_root_spec.js | 2 + .../components/service_desk_setting_spec.js | 80 +- .../service_desk_template_dropdown_spec.js | 80 ++ .../components/storage_table_spec.js | 5 +- .../components/storage_type_icon_spec.js | 41 + .../frontend/projects/storage_counter/mock_data.js | 33 +- .../projects/storage_counter/utils_spec.js | 17 + .../upload_file_experiment_tracking_spec.js | 43 - .../__snapshots__/registry_breadcrumb_spec.js.snap | 72 -- .../explorer/components/delete_button_spec.js | 73 -- .../explorer/components/delete_image_spec.js | 152 --- .../__snapshots__/tags_loader_spec.js.snap | 61 - .../components/details_page/delete_alert_spec.js | 116 -- .../components/details_page/delete_modal_spec.js | 152 --- .../components/details_page/details_header_spec.js | 304 ----- .../components/details_page/empty_state_spec.js | 54 - .../details_page/partial_cleanup_alert_spec.js | 71 - .../components/details_page/status_alert_spec.js | 57 - .../components/details_page/tags_list_row_spec.js | 382 ------ .../components/details_page/tags_list_spec.js | 311 ----- .../components/details_page/tags_loader_spec.js | 45 - .../__snapshots__/group_empty_state_spec.js.snap | 15 - .../__snapshots__/project_empty_state_spec.js.snap | 83 -- .../components/list_page/cleanup_status_spec.js | 87 -- .../components/list_page/cli_commands_spec.js | 94 -- .../components/list_page/group_empty_state_spec.js | 37 - .../components/list_page/image_list_row_spec.js | 223 ---- .../components/list_page/image_list_spec.js | 88 -- .../list_page/project_empty_state_spec.js | 45 - .../components/list_page/registry_header_spec.js | 135 -- .../components/registry_breadcrumb_spec.js | 78 -- spec/frontend/registry/explorer/mock_data.js | 269 ---- .../registry/explorer/pages/details_spec.js | 521 -------- .../frontend/registry/explorer/pages/index_spec.js | 24 - spec/frontend/registry/explorer/pages/list_spec.js | 597 --------- spec/frontend/registry/explorer/stubs.js | 45 - spec/frontend/registry/shared/mocks.js | 3 - spec/frontend/registry/shared/stubs.js | 31 - .../components/related_merge_requests_spec.js | 4 +- .../releases/components/tag_field_new_spec.js | 4 +- .../components/blob_content_viewer_spec.js | 399 ++---- .../components/upload_blob_modal_spec.js | 10 - spec/frontend/repository/mock_data.js | 57 + .../runner/admin_runners/admin_runners_app_spec.js | 39 +- .../components/cells/runner_actions_cell_spec.js | 27 +- .../components/cells/runner_status_cell_spec.js | 69 + .../components/cells/runner_summary_cell_spec.js | 39 +- .../components/cells/runner_type_cell_spec.js | 48 - .../runner/components/helpers/masked_value_spec.js | 51 - .../registration/registration_dropdown_spec.js | 169 +++ .../registration_token_reset_dropdown_item_spec.js | 194 +++ .../registration/registration_token_spec.js | 109 ++ .../runner_contacted_state_badge_spec.js | 86 ++ .../components/runner_filtered_search_bar_spec.js | 60 +- .../frontend/runner/components/runner_list_spec.js | 59 +- .../components/runner_manual_setup_help_spec.js | 122 -- .../runner/components/runner_paused_badge_spec.js | 45 + .../runner_registration_token_reset_spec.js | 189 --- .../components/runner_state_locked_badge_spec.js | 45 - .../components/runner_state_paused_badge_spec.js | 45 - spec/frontend/runner/components/runner_tag_spec.js | 46 +- .../frontend/runner/components/runner_tags_spec.js | 10 +- .../runner/components/runner_type_alert_spec.js | 14 +- .../runner/components/runner_type_badge_spec.js | 14 +- .../runner/components/runner_type_tabs_spec.js | 109 ++ .../runner/group_runners/group_runners_app_spec.js | 37 +- spec/frontend/runner/runner_search_utils_spec.js | 36 +- .../frontend/search/sidebar/components/app_spec.js | 56 +- spec/frontend/search/store/actions_spec.js | 31 +- spec/frontend/search/store/mutations_spec.js | 10 + spec/frontend/search/store/utils_spec.js | 29 +- .../security_configuration/components/app_spec.js | 43 +- .../components/feature_card_spec.js | 11 +- .../assignees/uncollapsed_assignee_list_spec.js | 21 +- .../components/attention_required_toggle_spec.js | 84 ++ .../reviewers/uncollapsed_reviewer_list_spec.js | 20 +- .../components/time_tracking/report_spec.js | 2 +- spec/frontend/sidebar/sidebar_mediator_spec.js | 55 + spec/frontend/task_list_spec.js | 17 +- spec/frontend/terms/components/app_spec.js | 171 +++ spec/frontend/test_setup.js | 6 +- .../components/approvals/approvals_summary_spec.js | 53 +- .../components/extensions/actions_spec.js | 12 + .../components/mr_widget_pipeline_spec.js | 6 + .../components/mr_widget_suggest_pipeline_spec.js | 25 +- .../mr_widget_auto_merge_enabled_spec.js.snap | 4 +- .../__snapshots__/new_ready_to_merge_spec.js.snap | 4 +- .../components/states/commit_edit_spec.js | 11 +- .../states/mr_widget_auto_merge_enabled_spec.js | 4 +- .../states/mr_widget_commits_header_spec.js | 4 +- .../components/states/mr_widget_merged_spec.js | 6 +- .../components/states/mr_widget_merging_spec.js | 2 +- .../states/mr_widget_ready_to_merge_spec.js | 13 - .../states/mr_widget_squash_before_merge_spec.js | 8 +- .../components/states/mr_widget_wip_spec.js | 8 +- spec/frontend/vue_mr_widget/mock_data.js | 2 +- .../vue_mr_widget/mr_widget_options_spec.js | 28 +- .../vue_mr_widget/stores/get_state_key_spec.js | 9 +- .../vue_mr_widget/stores/mr_widget_store_spec.js | 2 +- spec/frontend/vue_mr_widget/test_extension.js | 2 + .../components/alerts_deprecation_warning_spec.js | 48 - .../confirm_danger/confirm_danger_modal_spec.js | 99 ++ .../confirm_danger/confirm_danger_spec.js | 61 + .../content_viewer/content_viewer_spec.js | 2 +- .../content_viewer/viewers/markdown_viewer_spec.js | 2 +- .../components/dropdown/dropdown_widget_spec.js | 6 +- .../filtered_search_bar_root_spec.js | 27 +- .../components/filtered_search_bar/mock_data.js | 26 + .../tokens/author_token_spec.js | 29 + .../tokens/iteration_token_spec.js | 44 +- .../tokens/release_token_spec.js | 78 ++ .../components/header_ci_component_spec.js | 48 +- .../components/notes/system_note_spec.js | 50 +- .../project_selector/project_list_item_spec.js | 6 +- .../project_selector/project_selector_spec.js | 6 +- .../components/registry/title_area_spec.js | 59 +- .../runner_instructions_modal_spec.js | 67 +- .../__snapshots__/settings_block_spec.js.snap | 1 + .../components/settings/settings_block_spec.js | 48 +- .../sidebar/collapsed_calendar_icon_spec.js | 76 +- .../sidebar/collapsed_grouped_date_picker_spec.js | 119 +- .../components/sidebar/date_picker_spec.js | 69 +- .../dropdown_value_collapsed_spec.js | 121 +- .../labels_select_vue/store/mutations_spec.js | 72 +- .../dropdown_contents_create_view_spec.js | 18 +- .../dropdown_contents_labels_view_spec.js | 19 +- .../labels_select_widget/dropdown_contents_spec.js | 69 +- .../labels_select_widget/dropdown_footer_spec.js | 57 + .../labels_select_widget/dropdown_header_spec.js | 75 ++ .../labels_select_root_spec.js | 8 +- .../sidebar/labels_select_widget/mock_data.js | 7 +- .../components/sidebar/toggle_sidebar_spec.js | 52 +- .../user_deletion_obstacles_list_spec.js | 2 +- spec/frontend/whats_new/utils/notification_spec.js | 6 +- spec/frontend/work_items/components/app_spec.js | 24 + spec/frontend/work_items/mock_data.js | 17 + .../work_items/pages/work_item_root_spec.js | 70 + spec/frontend/work_items/router_spec.js | 30 + .../customer_relations/contacts/create_spec.rb | 6 +- .../customer_relations/contacts/update_spec.rb | 6 +- .../organizations/create_spec.rb | 6 +- .../organizations/update_spec.rb | 6 +- .../mutations/discussions/toggle_resolve_spec.rb | 4 +- .../environments/canary_ingress/update_spec.rb | 2 +- .../mutations/merge_requests/set_wip_spec.rb | 55 - .../notes/reposition_image_diff_note_spec.rb | 2 +- spec/graphql/mutations/releases/delete_spec.rb | 2 +- spec/graphql/mutations/releases/update_spec.rb | 4 +- .../ci_configuration/configure_sast_iac_spec.rb | 13 + .../resolvers/concerns/resolves_groups_spec.rb | 71 + .../resolvers/concerns/resolves_pipelines_spec.rb | 22 +- .../resolvers/group_issues_resolver_spec.rb | 65 +- spec/graphql/resolvers/issues_resolver_spec.rb | 120 +- .../resolvers/merge_requests_resolver_spec.rb | 48 + .../projects/jira_projects_resolver_spec.rb | 5 +- spec/graphql/resolvers/timelog_resolver_spec.rb | 50 +- spec/graphql/resolvers/topics_resolver_spec.rb | 33 + .../prometheus_integration_type_spec.rb | 2 +- spec/graphql/types/ci/job_artifact_type_spec.rb | 2 +- spec/graphql/types/ci/pipeline_scope_enum_spec.rb | 11 + spec/graphql/types/ci/pipeline_status_enum_spec.rb | 11 + spec/graphql/types/ci/pipeline_type_spec.rb | 4 +- spec/graphql/types/commit_type_spec.rb | 2 +- .../types/customer_relations/contact_type_spec.rb | 2 +- .../customer_relations/organization_type_spec.rb | 2 +- .../types/dependency_proxy/manifest_type_spec.rb | 2 +- spec/graphql/types/evidence_type_spec.rb | 2 +- .../types/merge_request_review_state_enum_spec.rb | 4 + spec/graphql/types/merge_request_type_spec.rb | 2 +- spec/graphql/types/mutation_type_spec.rb | 8 - .../types/packages/helm/dependency_type_spec.rb | 15 + .../packages/helm/file_metadatum_type_spec.rb | 15 + .../types/packages/helm/maintainer_type_spec.rb | 15 + .../types/packages/helm/metadata_type_spec.rb | 15 + spec/graphql/types/project_type_spec.rb | 4 +- spec/graphql/types/projects/topic_type_spec.rb | 17 + spec/graphql/types/query_type_spec.rb | 1 + spec/graphql/types/release_links_type_spec.rb | 44 +- spec/graphql/types/repository/blob_type_spec.rb | 1 + .../user_merge_request_interaction_type_spec.rb | 5 +- spec/helpers/admin/deploy_key_helper_spec.rb | 28 + spec/helpers/boards_helper_spec.rb | 6 +- spec/helpers/ci/pipelines_helper_spec.rb | 22 + spec/helpers/ci/runners_helper_spec.rb | 72 +- spec/helpers/clusters_helper_spec.rb | 120 +- spec/helpers/emoji_helper_spec.rb | 5 +- spec/helpers/environments_helper_spec.rb | 64 +- spec/helpers/graph_helper_spec.rb | 12 + spec/helpers/groups/settings_helper_spec.rb | 38 + spec/helpers/groups_helper_spec.rb | 2 +- spec/helpers/invite_members_helper_spec.rb | 79 +- .../issuables_description_templates_helper_spec.rb | 14 +- spec/helpers/issuables_helper_spec.rb | 23 +- spec/helpers/issues_helper_spec.rb | 1 + spec/helpers/learn_gitlab_helper_spec.rb | 155 ++- spec/helpers/members_helper_spec.rb | 6 + spec/helpers/nav/top_nav_helper_spec.rb | 5 + spec/helpers/notes_helper_spec.rb | 12 +- spec/helpers/one_trust_helper_spec.rb | 19 +- .../projects/alert_management_helper_spec.rb | 33 +- spec/helpers/projects/incidents_helper_spec.rb | 49 +- .../projects/security/configuration_helper_spec.rb | 2 +- spec/helpers/projects_helper_spec.rb | 18 +- .../routing/pseudonymization_helper_spec.rb | 220 +++- spec/helpers/storage_helper_spec.rb | 19 +- spec/helpers/tab_helper_spec.rb | 28 +- spec/helpers/terms_helper_spec.rb | 44 + spec/helpers/time_zone_helper_spec.rb | 32 + spec/helpers/user_callouts_helper_spec.rb | 14 - spec/helpers/users_helper_spec.rb | 2 +- spec/helpers/wiki_helper_spec.rb | 6 +- spec/initializers/0_postgresql_types_spec.rb | 16 + .../initializers/100_patch_omniauth_oauth2_spec.rb | 45 +- spec/initializers/carrierwave_patch_spec.rb | 3 - spec/initializers/database_config_spec.rb | 49 +- spec/initializers/session_store_spec.rb | 37 + spec/lib/api/ci/helpers/runner_spec.rb | 8 +- spec/lib/api/entities/projects/topic_spec.rb | 19 + spec/lib/api/helpers_spec.rb | 2 + spec/lib/atlassian/jira_connect/client_spec.rb | 10 +- spec/lib/banzai/filter/emoji_filter_spec.rb | 6 +- spec/lib/banzai/filter/footnote_filter_spec.rb | 88 +- spec/lib/banzai/filter/markdown_filter_spec.rb | 153 ++- spec/lib/banzai/filter/plantuml_filter_spec.rb | 73 +- spec/lib/banzai/filter/sanitization_filter_spec.rb | 64 +- .../banzai/filter/syntax_highlight_filter_spec.rb | 232 ++-- spec/lib/banzai/pipeline/emoji_pipeline_spec.rb | 6 +- spec/lib/banzai/pipeline/full_pipeline_spec.rb | 67 +- .../pipeline/plain_markdown_pipeline_spec.rb | 55 +- spec/lib/banzai/renderer_spec.rb | 18 + .../common/pipelines/milestones_pipeline_spec.rb | 154 +++ .../common/pipelines/uploads_pipeline_spec.rb | 80 ++ .../common/pipelines/wiki_pipeline_spec.rb | 25 + .../groups/graphql/get_milestones_query_spec.rb | 35 - .../groups/loaders/group_loader_spec.rb | 58 +- .../groups/pipelines/milestones_pipeline_spec.rb | 73 -- spec/lib/bulk_imports/groups/stage_spec.rb | 2 +- spec/lib/bulk_imports/ndjson_pipeline_spec.rb | 3 + .../external_pull_requests_pipeline_spec.rb | 66 + .../pipelines/merge_requests_pipeline_spec.rb | 297 +++++ .../pipelines/protected_branches_pipeline_spec.rb | 61 + .../projects/pipelines/repository_pipeline_spec.rb | 97 +- spec/lib/bulk_imports/projects/stage_spec.rb | 11 +- spec/lib/container_registry/client_spec.rb | 2 +- spec/lib/container_registry/tag_spec.rb | 2 +- .../collector/payload_validator_spec.rb | 49 + .../collector/sentry_request_parser_spec.rb | 7 - spec/lib/feature/gitaly_spec.rb | 4 +- spec/lib/feature_spec.rb | 16 +- .../usage_metric_definition_generator_spec.rb | 11 + .../aggregated/base_query_builder_spec.rb | 150 +++ .../aggregated/records_fetcher_spec.rb | 130 ++ spec/lib/gitlab/application_rate_limiter_spec.rb | 132 +- spec/lib/gitlab/asciidoc_spec.rb | 1351 ++++++++++---------- spec/lib/gitlab/auth/auth_finders_spec.rb | 64 +- ...modified_to_approval_merge_request_rule_spec.rb | 2 +- ...imary_email_to_emails_if_user_confirmed_spec.rb | 49 + .../backfill_artifact_expiry_date_spec.rb | 2 +- ...ll_deployment_clusters_from_deployments_spec.rb | 2 +- .../backfill_design_internal_ids_spec.rb | 69 - ...nvironment_id_deployment_merge_requests_spec.rb | 2 +- .../backfill_jira_tracker_deployment_type2_spec.rb | 2 +- ...ackfill_merge_request_cleanup_schedules_spec.rb | 2 +- .../backfill_namespace_settings_spec.rb | 2 +- .../backfill_project_settings_spec.rb | 2 +- .../backfill_push_rules_id_in_projects_spec.rb | 2 +- .../backfill_user_namespace_spec.rb | 39 + ...y_column_using_background_migration_job_spec.rb | 2 +- ...target_project_to_merge_request_metrics_spec.rb | 39 - .../drop_invalid_vulnerabilities_spec.rb | 2 +- .../fix_merge_request_diff_commit_users_spec.rb | 316 +++++ .../fix_projects_without_project_feature_spec.rb | 75 -- ...fix_projects_without_prometheus_service_spec.rb | 234 ---- .../background_migration/job_coordinator_spec.rb | 344 +++++ .../link_lfs_objects_projects_spec.rb | 2 +- .../migrate_fingerprint_sha256_within_keys_spec.rb | 2 +- .../migrate_issue_trackers_sensitive_data_spec.rb | 327 ----- ...migrate_merge_request_diff_commit_users_spec.rb | 2 +- .../migrate_u2f_webauthn_spec.rb | 2 +- .../migrate_users_bio_to_user_details_spec.rb | 85 -- .../populate_canonical_emails_spec.rb | 2 +- ...ate_dismissed_state_for_vulnerabilities_spec.rb | 2 +- ...finding_uuid_for_vulnerability_feedback_spec.rb | 2 +- .../populate_has_vulnerabilities_spec.rb | 2 +- .../populate_issue_email_participants_spec.rb | 2 +- ...ing_vulnerability_dismissal_information_spec.rb | 2 +- .../populate_personal_snippet_statistics_spec.rb | 4 +- .../populate_project_snippet_statistics_spec.rb | 4 +- .../populate_user_highest_roles_table_spec.rb | 71 - .../backfill_project_namespaces_spec.rb | 254 ++++ ...ect_authorizations_with_min_max_user_id_spec.rb | 2 +- ...culate_vulnerabilities_occurrences_uuid_spec.rb | 2 +- .../remove_duplicate_services_spec.rb | 2 +- ...move_duplicate_vulnerabilities_findings_spec.rb | 77 +- .../replace_blocked_by_links_spec.rb | 2 +- ...shared_runners_for_transferred_projects_spec.rb | 2 +- .../set_default_iteration_cadences_spec.rb | 80 -- .../set_merge_request_diff_files_count_spec.rb | 2 +- ...null_external_diff_store_to_local_value_spec.rb | 33 - ...package_files_file_store_to_local_value_spec.rb | 33 - ...migrate_merge_request_diff_commit_users_spec.rb | 2 +- ...oup_to_match_visibility_level_of_parent_spec.rb | 2 +- ...ting_users_that_require_two_factor_auth_spec.rb | 2 +- .../create_resource_user_mention_spec.rb | 114 +- .../wrongfully_confirmed_email_unconfirmer_spec.rb | 2 +- spec/lib/gitlab/background_migration_spec.rb | 257 +--- .../gitlab/bare_repository_import/importer_spec.rb | 2 - .../bitbucket_server_import/importer_spec.rb | 74 +- spec/lib/gitlab/blob_helper_spec.rb | 12 +- spec/lib/gitlab/ci/artifact_file_reader_spec.rb | 11 - spec/lib/gitlab/ci/artifacts/metrics_spec.rb | 6 +- spec/lib/gitlab/ci/build/auto_retry_spec.rb | 2 + .../ci/build/rules/rule/clause/exists_spec.rb | 28 +- .../ci/config/entry/include/rules/rule_spec.rb | 16 +- .../lib/gitlab/ci/config/entry/processable_spec.rb | 8 + spec/lib/gitlab/ci/config/extendable_spec.rb | 44 + .../gitlab/ci/config/external/processor_spec.rb | 2 +- spec/lib/gitlab/ci/config/external/rules_spec.rb | 28 +- spec/lib/gitlab/ci/config_spec.rb | 90 +- .../ci/pipeline/chain/validate/external_spec.rb | 2 +- .../gitlab/ci/pipeline/quota/deployments_spec.rb | 6 +- spec/lib/gitlab/ci/pipeline/seed/build_spec.rb | 56 +- spec/lib/gitlab/ci/reports/security/report_spec.rb | 22 + .../lib/gitlab/ci/reports/security/reports_spec.rb | 21 +- .../templates/Jobs/deploy_gitlab_ci_yaml_spec.rb | 30 +- .../templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb | 65 + .../templates/auto_devops_gitlab_ci_yaml_spec.rb | 20 +- .../ci/templates/kaniko_gitlab_ci_yaml_spec.rb | 25 + .../terraform_latest_gitlab_ci_yaml_spec.rb | 2 +- spec/lib/gitlab/ci/trace/archive_spec.rb | 169 ++- spec/lib/gitlab/ci/trace/metrics_spec.rb | 18 +- spec/lib/gitlab/ci/trace_spec.rb | 10 - spec/lib/gitlab/ci/variables/builder_spec.rb | 38 + spec/lib/gitlab/ci/variables/collection_spec.rb | 482 +++---- spec/lib/gitlab/ci/yaml_processor_spec.rb | 58 + .../external_database_checker_spec.rb | 6 +- .../gitlab/container_repository/tags/cache_spec.rb | 133 ++ .../content_security_policy/config_loader_spec.rb | 49 +- spec/lib/gitlab/contributions_calendar_spec.rb | 68 +- .../async_indexes/postgres_async_index_spec.rb | 2 + .../batched_migration_runner_spec.rb | 2 +- spec/lib/gitlab/database/batch_count_spec.rb | 2 +- spec/lib/gitlab/database/connection_spec.rb | 442 ------- .../count/reltuples_count_strategy_spec.rb | 3 +- .../count/tablesample_count_strategy_spec.rb | 3 +- spec/lib/gitlab/database/each_database_spec.rb | 48 + spec/lib/gitlab/database/gitlab_schema_spec.rb | 58 + .../database/load_balancing/configuration_spec.rb | 75 +- .../load_balancing/connection_proxy_spec.rb | 45 +- .../database/load_balancing/load_balancer_spec.rb | 102 +- .../database/load_balancing/primary_host_spec.rb | 6 +- .../load_balancing/rack_middleware_spec.rb | 16 +- .../gitlab/database/load_balancing/setup_spec.rb | 208 ++- .../sidekiq_client_middleware_spec.rb | 4 +- .../sidekiq_server_middleware_spec.rb | 6 +- .../database/load_balancing/sticking_spec.rb | 22 +- spec/lib/gitlab/database/load_balancing_spec.rb | 14 +- .../loose_foreign_key_helpers_spec.rb | 10 +- .../gitlab/database/migration_helpers/v2_spec.rb | 62 +- spec/lib/gitlab/database/migration_helpers_spec.rb | 129 +- .../background_migration_helpers_spec.rb | 23 +- .../observers/transaction_duration_spec.rb | 106 ++ .../detached_partition_dropper_spec.rb | 95 +- .../database/partitioning/monthly_strategy_spec.rb | 42 +- .../multi_database_partition_dropper_spec.rb | 38 - .../multi_database_partition_manager_spec.rb | 36 - .../partitioning/partition_manager_spec.rb | 2 +- .../partitioning/partition_monitoring_spec.rb | 3 +- .../database/partitioning/replace_table_spec.rb | 4 +- spec/lib/gitlab/database/partitioning_spec.rb | 173 ++- .../gitlab/database/postgres_foreign_key_spec.rb | 12 + .../postgres_hll/batch_distinct_counter_spec.rb | 2 +- .../database/postgres_index_bloat_estimate_spec.rb | 2 + spec/lib/gitlab/database/postgres_index_spec.rb | 2 + spec/lib/gitlab/database/query_analyzer_spec.rb | 144 +++ .../query_analyzers/gitlab_schemas_metrics_spec.rb | 80 ++ .../prevent_cross_database_modification_spec.rb | 167 +++ spec/lib/gitlab/database/reflection_spec.rb | 280 ++++ .../database/reindexing/index_selection_spec.rb | 6 +- .../database/reindexing/reindex_action_spec.rb | 2 + .../reindexing/reindex_concurrently_spec.rb | 4 +- spec/lib/gitlab/database/reindexing_spec.rb | 112 +- .../schema_cache_with_renamed_table_spec.rb | 12 +- .../database/schema_migrations/context_spec.rb | 2 +- spec/lib/gitlab/database/shared_model_spec.rb | 32 + .../database/unidirectional_copy_trigger_spec.rb | 2 +- spec/lib/gitlab/database_spec.rb | 33 +- spec/lib/gitlab/diff/file_spec.rb | 42 + .../diff/position_tracer/line_strategy_spec.rb | 7 +- .../email/handler/service_desk_handler_spec.rb | 123 +- .../email/hook/smime_signature_interceptor_spec.rb | 2 +- .../message/in_product_marketing/base_spec.rb | 25 + .../in_product_marketing/experience_spec.rb | 69 +- .../in_product_marketing/invite_team_spec.rb | 39 + .../email/message/in_product_marketing_spec.rb | 13 +- spec/lib/gitlab/email/reply_parser_spec.rb | 24 + spec/lib/gitlab/emoji_spec.rb | 106 +- spec/lib/gitlab/etag_caching/middleware_spec.rb | 2 +- spec/lib/gitlab/git/commit_spec.rb | 8 + spec/lib/gitlab/git/object_pool_spec.rb | 2 +- spec/lib/gitlab/git/repository_spec.rb | 55 +- .../gitlab/gitaly_client/commit_service_spec.rb | 37 +- spec/lib/gitlab/gitaly_client/ref_service_spec.rb | 51 + spec/lib/gitlab/gitaly_client_spec.rb | 23 - .../gitlab/github_import/bulk_importing_spec.rb | 8 +- .../importer/diff_note_importer_spec.rb | 298 +++-- .../importer/diff_notes_importer_spec.rb | 2 + .../github_import/importer/issue_importer_spec.rb | 4 +- .../importer/label_links_importer_spec.rb | 8 +- .../github_import/importer/note_importer_spec.rb | 14 +- .../pull_requests_merged_by_importer_spec.rb | 25 +- .../github_import/representation/diff_note_spec.rb | 446 ++++--- .../diff_notes/suggestion_formatter_spec.rb | 50 +- spec/lib/gitlab/gpg/commit_spec.rb | 69 +- .../gpg/invalid_gpg_signature_updater_spec.rb | 2 + .../grape_logging/loggers/perf_logger_spec.rb | 2 +- .../loggers/queue_duration_logger_spec.rb | 4 +- .../grape_logging/loggers/urgency_logger_spec.rb | 48 + spec/lib/gitlab/graphql/known_operations_spec.rb | 80 ++ .../gitlab/graphql/pagination/connections_spec.rb | 6 +- .../query_analyzers/logger_analyzer_spec.rb | 15 +- .../tracers/application_context_tracer_spec.rb | 43 + .../gitlab/graphql/tracers/logger_tracer_spec.rb | 52 + .../gitlab/graphql/tracers/metrics_tracer_spec.rb | 60 + .../gitlab/graphql/tracers/timer_tracer_spec.rb | 44 + .../gitlab/health_checks/redis/redis_check_spec.rb | 2 +- spec/lib/gitlab/import/database_helpers_spec.rb | 4 +- spec/lib/gitlab/import/metrics_spec.rb | 14 - spec/lib/gitlab/import_export/all_models.yml | 3 + .../import_export/attributes_permitter_spec.rb | 83 +- .../import_export/fast_hash_serializer_spec.rb | 4 +- .../group/relation_tree_restorer_spec.rb | 88 ++ .../import_export/project/object_builder_spec.rb | 132 ++ .../project/relation_tree_restorer_spec.rb | 150 +++ .../project/sample/relation_tree_restorer_spec.rb | 48 +- .../import_export/project/tree_restorer_spec.rb | 3 +- .../import_export/project/tree_saver_spec.rb | 50 +- .../import_export/relation_tree_restorer_spec.rb | 184 --- .../gitlab/import_export/safe_model_attributes.yml | 2 + .../instrumentation/redis_interceptor_spec.rb | 41 - spec/lib/gitlab/instrumentation_helper_spec.rb | 19 + spec/lib/gitlab/issues/rebalancing/state_spec.rb | 29 +- spec/lib/gitlab/lograge/custom_options_spec.rb | 20 +- .../merge_requests/merge_commit_message_spec.rb | 219 ++++ .../gitlab/metrics/background_transaction_spec.rb | 47 +- spec/lib/gitlab/metrics/method_call_spec.rb | 4 +- spec/lib/gitlab/metrics/rails_slis_spec.rb | 37 +- .../metrics/requests_rack_middleware_spec.rb | 70 +- .../metrics/samplers/action_cable_sampler_spec.rb | 68 +- .../metrics/samplers/database_sampler_spec.rb | 4 +- .../metrics/subscribers/external_http_spec.rb | 2 +- spec/lib/gitlab/metrics/transaction_spec.rb | 167 +-- spec/lib/gitlab/metrics/web_transaction_spec.rb | 90 +- spec/lib/gitlab/middleware/compressed_json_spec.rb | 75 ++ spec/lib/gitlab/middleware/go_spec.rb | 16 + spec/lib/gitlab/middleware/query_analyzer_spec.rb | 61 + spec/lib/gitlab/path_regex_spec.rb | 11 + spec/lib/gitlab/project_template_spec.rb | 4 +- spec/lib/gitlab/prometheus_client_spec.rb | 36 +- spec/lib/gitlab/redis/multi_store_spec.rb | 474 +++++++ spec/lib/gitlab/runtime_spec.rb | 24 +- spec/lib/gitlab/search_results_spec.rb | 12 + spec/lib/gitlab/sidekiq_cluster/cli_spec.rb | 334 ----- spec/lib/gitlab/sidekiq_cluster_spec.rb | 207 --- spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb | 15 +- spec/lib/gitlab/sidekiq_config/worker_spec.rb | 17 +- spec/lib/gitlab/sidekiq_enq_spec.rb | 93 ++ .../sidekiq_logging/deduplication_logger_spec.rb | 30 +- .../gitlab/sidekiq_logging/json_formatter_spec.rb | 2 + .../duplicate_jobs/duplicate_job_spec.rb | 285 ++++- .../strategies/until_executed_spec.rb | 25 + .../sidekiq_middleware/query_analyzer_spec.rb | 61 + .../size_limiter/validator_spec.rb | 157 +-- .../worker_context/client_spec.rb | 10 +- spec/lib/gitlab/spamcheck/client_spec.rb | 2 +- spec/lib/gitlab/subscription_portal_spec.rb | 31 +- .../destinations/product_analytics_spec.rb | 84 -- .../tracking/destinations/snowplow_micro_spec.rb | 51 + spec/lib/gitlab/tracking/standard_context_spec.rb | 20 - spec/lib/gitlab/tracking_spec.rb | 89 +- spec/lib/gitlab/usage/metric_definition_spec.rb | 2 + spec/lib/gitlab/usage/metric_spec.rb | 6 + .../instrumentations/generic_metric_spec.rb | 12 +- .../metrics/names_suggestions/generator_spec.rb | 28 +- ...s_code_extenion_activity_unique_counter_spec.rb | 63 - ...vscode_extenion_activity_unique_counter_spec.rb | 63 + spec/lib/gitlab/usage_data_metrics_spec.rb | 16 +- spec/lib/gitlab/usage_data_spec.rb | 191 ++- spec/lib/gitlab/utils/usage_data_spec.rb | 26 +- spec/lib/gitlab/webpack/file_loader_spec.rb | 79 ++ .../webpack/graphql_known_operations_spec.rb | 47 + spec/lib/gitlab/workhorse_spec.rb | 18 + spec/lib/gitlab/x509/certificate_spec.rb | 50 + spec/lib/gitlab/x509/signature_spec.rb | 92 +- spec/lib/gitlab/zentao/client_spec.rb | 70 +- spec/lib/gitlab/zentao/query_spec.rb | 61 + spec/lib/marginalia_spec.rb | 6 +- spec/lib/object_storage/config_spec.rb | 40 - spec/lib/object_storage/direct_upload_spec.rb | 4 - .../ci_configuration/sast_iac_build_action_spec.rb | 163 +++ .../groups/menus/invite_team_members_menu_spec.rb | 55 + .../groups/menus/packages_registries_menu_spec.rb | 23 +- .../projects/menus/infrastructure_menu_spec.rb | 16 + .../menus/invite_team_members_menu_spec.rb | 52 + .../sidebars/projects/menus/settings_menu_spec.rb | 20 +- .../sidebars/projects/menus/zentao_menu_spec.rb | 7 + spec/lib/system_check/incoming_email_check_spec.rb | 4 +- spec/lib/uploaded_file_spec.rb | 64 +- spec/mailers/emails/in_product_marketing_spec.rb | 49 +- spec/mailers/emails/pipelines_spec.rb | 21 +- spec/mailers/notify_spec.rb | 87 +- ...20_add_timestamp_softwarelicensespolicy_spec.rb | 23 - ...0200122123016_backfill_project_settings_spec.rb | 32 - ...20200123155929_remove_invalid_jira_data_spec.rb | 77 -- ...90233_remove_invalid_issue_tracker_data_spec.rb | 64 - ..._reschedule_migrate_issue_trackers_data_spec.rb | 115 -- ...200313203550_remove_orphaned_chat_names_spec.rb | 27 - ...ll_deployment_clusters_from_deployments_spec.rb | 50 - ..._interpolation_format_in_common_metrics_spec.rb | 39 - .../20200526115436_dedup_mr_metrics_spec.rb | 68 - ...val_rule_name_for_code_owners_rule_type_spec.rb | 175 --- ...00703125016_backfill_namespace_settings_spec.rb | 30 - ...st_unique_index_alert_management_alerts_spec.rb | 57 - ..._unique_index_on_cycle_analytics_stages_spec.rb | 47 - ...311_add_o_auth_paths_to_protected_paths_spec.rb | 52 - ...ate_missing_vulnerabilities_issue_links_spec.rb | 160 --- ...25_schedule_migration_to_hashed_storage_spec.rb | 14 - ...ons_for_pre_versioning_terraform_states_spec.rb | 46 - ...kfill_jira_tracker_deployment_type_jobs_spec.rb | 58 - ...1_migrate_services_to_http_integrations_spec.rb | 26 - ..._backfill_jira_tracker_deployment_type2_spec.rb | 38 - ...anup_transfered_projects_shared_runners_spec.rb | 32 - ...move_duplicate_vulnerabilities_findings_spec.rb | 140 -- ...ate_uuid_on_vulnerabilities_occurrences_spec.rb | 138 -- ...210112143418_remove_duplicate_services2_spec.rb | 2 +- ...a_issue_first_mentioned_in_commit_value_spec.rb | 2 +- ...4_remove_bad_dependency_proxy_manifests_spec.rb | 2 +- ...pdated_at_after_repository_storage_move_spec.rb | 2 +- ...dd_environment_scope_to_group_variables_spec.rb | 2 +- .../20210226141517_dedup_issue_metrics_spec.rb | 2 +- ...otal_tuple_count_for_batched_migrations_spec.rb | 2 +- ...schedule_artifact_expiry_backfill_again_spec.rb | 2 +- ...acker_data_deployment_type_based_on_url_spec.rb | 2 +- ...7_schedule_drop_invalid_vulnerabilities_spec.rb | 2 +- ...134202_copy_adoption_snapshot_namespace_spec.rb | 2 +- ...135954_copy_adoption_segments_namespace_spec.rb | 2 +- ...oject_value_stream_id_to_project_stages_spec.rb | 2 +- ..._schedule_drop_invalid_vulnerabilities2_spec.rb | 2 +- ...e_cleanup_orphaned_lfs_objects_projects_spec.rb | 2 +- .../20210601073400_fix_total_stage_in_vsa_spec.rb | 2 +- ...d_environments_add_index_and_constraint_spec.rb | 2 +- ...move_builds_email_service_from_services_spec.rb | 2 +- ..._delete_legacy_operations_feature_flags_spec.rb | 2 +- ...061716138_cascade_delete_freeze_periods_spec.rb | 2 +- ...request_diff_users_background_migration_spec.rb | 2 +- ...update_issuable_slas_where_issue_closed_spec.rb | 2 +- ...e_flags_correct_flexible_rollout_values_spec.rb | 2 +- ...10804150320_create_base_work_item_types_spec.rb | 2 +- ...ans_ci_daily_pipeline_schedule_triggers_spec.rb | 2 +- ...0811122206_update_external_project_bots_spec.rb | 2 +- ...8185845_backfill_projects_with_coverage_spec.rb | 2 +- ...d_triggers_for_ci_builds_runner_session_spec.rb | 2 +- ...10831203408_upsert_base_work_item_types_spec.rb | 2 +- ...columns_and_triggers_for_ci_build_needs_spec.rb | 2 +- ..._and_triggers_for_ci_build_trace_chunks_spec.rb | 2 +- ...orary_columns_and_triggers_for_taggings_spec.rb | 2 +- ...igint_conversion_for_ci_builds_metadata_spec.rb | 2 +- ...57_finalize_ci_builds_bigint_conversion_spec.rb | 2 +- ...ype_for_existing_approval_project_rules_spec.rb | 2 +- ...10_cleanup_orphan_project_access_tokens_spec.rb | 2 +- ...cleanup_bigint_conversion_for_ci_builds_spec.rb | 2 +- ..._drop_int4_columns_for_ci_job_artifacts_spec.rb | 2 +- ...op_int4_column_for_ci_sources_pipelines_spec.rb | 2 +- ...10922082019_drop_int4_column_for_events_spec.rb | 2 +- ...rop_int4_column_for_push_event_payloads_spec.rb | 2 +- ...ulate_topics_total_projects_count_cache_spec.rb | 2 +- ...migrate_merge_request_diff_commit_users_spec.rb | 48 + ...ove_duplicate_vulnerabilities_findings3_spec.rb | 168 +++ ...rge_request_diff_commit_users_migration_spec.rb | 63 + ...alue_stream_to_groups_with_group_stages_spec.rb | 44 - .../add_deploy_token_type_to_deploy_tokens_spec.rb | 24 - ...ident_settings_to_all_existing_projects_spec.rb | 93 -- spec/migrations/add_open_source_plan_spec.rb | 86 ++ ...ndex_to_ci_builds_table_on_user_id_name_spec.rb | 22 - ...orages_weighted_to_application_settings_spec.rb | 31 - ...partial_index_on_project_id_to_services_spec.rb | 22 - .../backfill_imported_snippet_repositories_spec.rb | 52 - .../backfill_operations_feature_flags_iid_spec.rb | 32 - .../backfill_snippet_repositories_spec.rb | 44 - ...ackfill_status_page_published_incidents_spec.rb | 54 - spec/migrations/backfill_user_namespace_spec.rb | 29 + ...ap_designs_filename_length_to_new_limit_spec.rb | 62 - spec/migrations/clean_grafana_url_spec.rb | 37 - .../cleanup_empty_commit_user_mentions_spec.rb | 36 - ...p_group_import_states_with_null_user_id_spec.rb | 101 -- ...ner_registry_enabled_to_project_feature_spec.rb | 45 + ...er_registry_enabled_to_project_features_spec.rb | 45 - ...anup_optimistic_locking_nulls_pt2_fixed_spec.rb | 45 - .../cleanup_optimistic_locking_nulls_spec.rb | 52 - ...cleanup_projects_with_missing_namespace_spec.rb | 142 -- .../cleanup_remaining_orphan_invites_spec.rb | 2 +- .../complete_namespace_settings_migration_spec.rb | 24 - spec/migrations/confirm_project_bot_users_spec.rb | 84 -- ...environment_for_self_monitoring_project_spec.rb | 68 - spec/migrations/deduplicate_epic_iids_spec.rb | 36 - ..._internal_ids_where_feature_flags_usage_spec.rb | 42 - .../delete_template_project_services_spec.rb | 21 - ...te_template_services_duplicated_by_type_spec.rb | 24 - .../delete_user_callout_alerts_moved_spec.rb | 30 - ...ate_prometheus_services_background_jobs_spec.rb | 89 -- .../drop_background_migration_jobs_spec.rb | 61 - ...ernal_diff_store_on_merge_request_diffs_spec.rb | 40 - ...sure_filled_file_store_on_package_files_spec.rb | 40 - .../ensure_namespace_settings_creation_spec.rb | 44 - .../ensure_target_project_id_is_filled_spec.rb | 30 - .../ensure_u2f_registrations_migrated_spec.rb | 41 - .../fill_file_store_ci_job_artifacts_spec.rb | 44 - .../migrations/fill_file_store_lfs_objects_spec.rb | 36 - spec/migrations/fill_store_uploads_spec.rb | 48 - .../fix_projects_without_project_feature_spec.rb | 42 - ...ix_projects_without_prometheus_services_spec.rb | 42 - .../migrations/generate_ci_jwt_signing_key_spec.rb | 42 - .../generate_missing_routes_for_bots_spec.rb | 80 -- .../insert_daily_invites_plan_limits_spec.rb | 55 - ...nsert_project_feature_flags_plan_limits_spec.rb | 76 -- ...e_all_merge_request_user_mentions_to_db_spec.rb | 35 - .../migrate_bot_type_to_user_type_spec.rb | 20 - .../migrate_commit_notes_mentions_to_db_spec.rb | 37 - ...ework_enum_to_database_framework_record_spec.rb | 52 - ...e_commit_signature_worker_sidekiq_queue_spec.rb | 44 - ...igrate_incident_issues_to_incident_type_spec.rb | 55 - .../migrate_merge_request_mentions_to_db_spec.rb | 31 - ...te_store_security_reports_sidekiq_queue_spec.rb | 33 - ..._to_report_approval_rules_sidekiq_queue_spec.rb | 33 - .../orphaned_invite_tokens_cleanup_spec.rb | 2 +- ...smissal_information_for_vulnerabilities_spec.rb | 31 - ...ve_additional_application_settings_rows_spec.rb | 27 - ...move_deprecated_jenkins_service_records_spec.rb | 29 - .../remove_duplicate_labels_from_groups_spec.rb | 227 ---- .../remove_duplicate_labels_from_project_spec.rb | 239 ---- ...ve_gitlab_issue_tracker_service_records_spec.rb | 19 - .../migrations/remove_orphan_service_hooks_spec.rb | 26 - .../remove_orphaned_invited_members_spec.rb | 57 - ...remove_packages_deprecated_dependencies_spec.rb | 30 - .../remove_security_dashboard_feature_flag_spec.rb | 53 - ...ure_flag_to_instance_security_dashboard_spec.rb | 53 - spec/migrations/rename_sitemap_namespace_spec.rb | 30 - .../rename_sitemap_root_namespaces_spec.rb | 36 - ...schedule_set_default_iteration_cadences_spec.rb | 41 - .../migrations/reseed_merge_trains_enabled_spec.rb | 26 - .../reseed_repository_storages_weighted_spec.rb | 43 - .../save_instance_administrators_group_id_spec.rb | 99 -- ...imary_email_to_emails_if_user_confirmed_spec.rb | 31 + ...dule_backfill_push_rules_id_in_projects_spec.rb | 49 - ...blocked_by_links_replacement_second_try_spec.rb | 37 - .../schedule_link_lfs_objects_projects_spec.rb | 76 -- ...erge_request_cleanup_schedules_backfill_spec.rb | 41 - .../schedule_migrate_security_scans_spec.rb | 67 - .../schedule_migrate_u2f_webauthn_spec.rb | 58 - .../schedule_populate_has_vulnerabilities_spec.rb | 36 - ...edule_populate_issue_email_participants_spec.rb | 33 - ...smissal_information_for_vulnerabilities_spec.rb | 37 - ...le_populate_personal_snippet_statistics_spec.rb | 60 - ...ule_populate_project_snippet_statistics_spec.rb | 61 - ...edule_populate_user_highest_roles_table_spec.rb | 46 - ...ulate_project_authorizations_second_run_spec.rb | 28 - ...dule_recalculate_project_authorizations_spec.rb | 57 - ...culate_project_authorizations_third_run_spec.rb | 28 - ...ate_historical_vulnerability_statistics_spec.rb | 36 - ...oup_to_match_visibility_level_of_parent_spec.rb | 79 -- ...ting_users_that_require_two_factor_auth_spec.rb | 29 - spec/migrations/seed_merge_trains_enabled_spec.rb | 28 - .../seed_repository_storages_weighted_spec.rb | 31 - ...es_remove_temporary_index_on_project_id_spec.rb | 40 - spec/migrations/set_job_waiter_ttl_spec.rb | 30 - ...ce_merge_request_diff_commit_migrations_spec.rb | 2 +- ...rge_request_diff_commit_users_migration_spec.rb | 2 +- .../unconfirm_wrongfully_verified_emails_spec.rb | 55 - ...npm_package_requests_forwarding_default_spec.rb | 38 - .../update_fingerprint_sha256_within_keys_spec.rb | 30 - .../update_historical_data_recorded_at_spec.rb | 31 - ...ternal_ids_last_value_for_epics_renamed_spec.rb | 30 - ...t_and_found_group_and_orphaned_projects_spec.rb | 223 ---- ...update_timestamp_softwarelicensespolicy_spec.rb | 24 - spec/models/ability_spec.rb | 4 +- spec/models/acts_as_taggable_on/tag_spec.rb | 16 + spec/models/acts_as_taggable_on/tagging_spec.rb | 16 + .../cycle_analytics/issue_stage_event_spec.rb | 9 +- .../merge_request_stage_event_spec.rb | 9 +- spec/models/blob_viewer/package_json_spec.rb | 52 +- spec/models/bulk_imports/entity_spec.rb | 9 + .../file_transfer/project_config_spec.rb | 2 +- spec/models/chat_name_spec.rb | 8 + spec/models/ci/bridge_spec.rb | 2 - spec/models/ci/build_metadata_spec.rb | 12 + spec/models/ci/build_spec.rb | 267 ++-- spec/models/ci/job_artifact_spec.rb | 62 + spec/models/ci/pipeline_schedule_spec.rb | 2 +- spec/models/ci/pipeline_spec.rb | 18 +- spec/models/ci/runner_spec.rb | 53 +- spec/models/ci/trigger_spec.rb | 4 + spec/models/clusters/applications/runner_spec.rb | 6 +- spec/models/clusters/cluster_spec.rb | 6 +- spec/models/commit_status_spec.rb | 16 + spec/models/concerns/bulk_insert_safe_spec.rb | 24 +- .../concerns/bulk_insertable_associations_spec.rb | 32 +- .../cascading_namespace_setting_attribute_spec.rb | 15 + .../agents/authorization_config_scopes_spec.rb | 21 + spec/models/concerns/database_reflection_spec.rb | 18 + spec/models/concerns/has_integrations_spec.rb | 25 - spec/models/concerns/legacy_bulk_insert_spec.rb | 103 ++ spec/models/concerns/loaded_in_group_list_spec.rb | 58 +- spec/models/concerns/loose_foreign_key_spec.rb | 29 +- spec/models/concerns/noteable_spec.rb | 64 + spec/models/concerns/prometheus_adapter_spec.rb | 8 + spec/models/concerns/reactive_caching_spec.rb | 2 +- spec/models/concerns/sha256_attribute_spec.rb | 2 +- spec/models/concerns/sha_attribute_spec.rb | 2 +- spec/models/concerns/where_composite_spec.rb | 2 +- .../concerns/x509_serial_number_attribute_spec.rb | 2 +- spec/models/custom_emoji_spec.rb | 2 +- spec/models/customer_relations/contact_spec.rb | 3 +- .../customer_relations/issue_contact_spec.rb | 48 + spec/models/data_list_spec.rb | 31 + spec/models/dependency_proxy/manifest_spec.rb | 21 +- spec/models/deploy_key_spec.rb | 11 + spec/models/deployment_spec.rb | 81 +- spec/models/design_management/version_spec.rb | 2 +- spec/models/email_spec.rb | 11 +- spec/models/environment_spec.rb | 44 +- spec/models/error_tracking/error_event_spec.rb | 20 + spec/models/error_tracking/error_spec.rb | 4 + spec/models/event_spec.rb | 12 +- spec/models/fork_network_spec.rb | 6 +- spec/models/generic_commit_status_spec.rb | 2 +- spec/models/grafana_integration_spec.rb | 6 +- spec/models/group_spec.rb | 55 +- spec/models/hooks/project_hook_spec.rb | 2 +- spec/models/identity_spec.rb | 10 +- spec/models/integration_spec.rb | 18 +- spec/models/integrations/jira_spec.rb | 148 ++- spec/models/integrations/pipelines_email_spec.rb | 38 +- spec/models/integrations/shimo_spec.rb | 41 + spec/models/integrations/zentao_spec.rb | 6 + spec/models/issue_spec.rb | 25 +- spec/models/jira_import_state_spec.rb | 4 +- spec/models/key_spec.rb | 4 +- .../loose_foreign_keys/deleted_record_spec.rb | 35 + .../modification_tracker_spec.rb | 93 ++ spec/models/member_spec.rb | 14 + spec/models/members/member_task_spec.rb | 124 ++ spec/models/members/project_member_spec.rb | 54 - spec/models/merge_request_assignee_spec.rb | 4 + spec/models/merge_request_diff_commit_spec.rb | 16 +- spec/models/merge_request_diff_spec.rb | 20 +- spec/models/merge_request_reviewer_spec.rb | 4 + spec/models/merge_request_spec.rb | 22 + spec/models/namespace_spec.rb | 295 +++-- spec/models/namespaces/project_namespace_spec.rb | 2 +- spec/models/note_spec.rb | 22 +- spec/models/notification_setting_spec.rb | 12 +- .../operations/feature_flags/strategy_spec.rb | 269 ++-- .../operations/feature_flags/user_list_spec.rb | 21 +- spec/models/packages/npm/metadatum_spec.rb | 50 + spec/models/packages/package_file_spec.rb | 23 +- spec/models/packages/package_spec.rb | 24 +- spec/models/pages_domain_spec.rb | 4 +- .../preloaders/group_policy_preloader_spec.rb | 45 + .../group_root_ancestor_preloader_spec.rb | 63 + ...er_max_access_level_in_groups_preloader_spec.rb | 49 +- spec/models/project_authorization_spec.rb | 21 +- spec/models/project_spec.rb | 113 +- spec/models/project_statistics_spec.rb | 4 +- spec/models/project_team_spec.rb | 14 + spec/models/protectable_dropdown_spec.rb | 2 +- spec/models/redirect_route_spec.rb | 10 +- spec/models/release_spec.rb | 14 +- spec/models/remote_mirror_spec.rb | 10 +- spec/models/repository_spec.rb | 79 +- spec/models/route_spec.rb | 14 +- spec/models/sentry_issue_spec.rb | 2 +- spec/models/snippet_spec.rb | 6 +- spec/models/suggestion_spec.rb | 8 + spec/models/u2f_registration_spec.rb | 28 +- spec/models/upload_spec.rb | 18 +- spec/models/uploads/fog_spec.rb | 27 +- spec/models/user_spec.rb | 138 +- spec/models/users/credit_card_validation_spec.rb | 18 +- .../users/in_product_marketing_email_spec.rb | 3 +- .../models/users/merge_request_interaction_spec.rb | 5 +- spec/models/users_statistics_spec.rb | 16 +- spec/models/webauthn_registration_spec.rb | 23 + spec/policies/group_policy_spec.rb | 33 +- .../namespaces/project_namespace_policy_spec.rb | 3 +- spec/policies/project_policy_spec.rb | 84 +- spec/presenters/award_emoji_presenter_spec.rb | 9 +- spec/presenters/blob_presenter_spec.rb | 55 + spec/presenters/ci/build_runner_presenter_spec.rb | 57 +- .../packages/npm/package_presenter_spec.rb | 88 +- spec/presenters/project_presenter_spec.rb | 74 +- spec/presenters/release_presenter_spec.rb | 14 - .../requests/admin/applications_controller_spec.rb | 18 + spec/requests/api/api_spec.rb | 24 +- spec/requests/api/ci/jobs_spec.rb | 173 ++- .../api/ci/runner/jobs_request_post_spec.rb | 6 +- spec/requests/api/debian_group_packages_spec.rb | 15 +- spec/requests/api/debian_project_packages_spec.rb | 21 +- spec/requests/api/deploy_keys_spec.rb | 54 +- spec/requests/api/error_tracking/collector_spec.rb | 51 +- spec/requests/api/features_spec.rb | 30 + spec/requests/api/files_spec.rb | 44 +- spec/requests/api/generic_packages_spec.rb | 31 + spec/requests/api/graphql/ci/pipelines_spec.rb | 63 + spec/requests/api/graphql/gitlab_schema_spec.rb | 23 +- .../group/dependency_proxy_manifests_spec.rb | 22 + .../ci/runners_registration_token/reset_spec.rb | 2 +- .../mutations/design_management/delete_spec.rb | 2 +- .../api/graphql/mutations/issues/create_spec.rb | 4 + .../api/graphql/mutations/issues/move_spec.rb | 2 +- .../mutations/issues/set_confidential_spec.rb | 2 +- .../mutations/issues/set_crm_contacts_spec.rb | 161 +++ .../graphql/mutations/issues/set_due_date_spec.rb | 2 +- .../graphql/mutations/issues/set_severity_spec.rb | 2 +- .../mutations/merge_requests/set_draft_spec.rb | 79 ++ .../mutations/merge_requests/set_wip_spec.rb | 79 -- .../merge_requests/update_reviewer_state_spec.rb | 65 + .../api/graphql/mutations/releases/create_spec.rb | 2 +- .../api/graphql/mutations/releases/delete_spec.rb | 2 +- .../api/graphql/mutations/releases/update_spec.rb | 4 +- .../ci_configuration/configure_sast_iac_spec.rb | 26 + spec/requests/api/graphql/namespace_query_spec.rb | 86 ++ spec/requests/api/graphql/packages/helm_spec.rb | 59 + spec/requests/api/graphql/project/issues_spec.rb | 4 +- .../api/graphql/project/merge_request_spec.rb | 16 +- spec/requests/api/graphql/project/release_spec.rb | 186 ++- spec/requests/api/graphql/project/releases_spec.rb | 15 +- spec/requests/api/graphql_spec.rb | 30 +- .../api/group_debian_distributions_spec.rb | 16 +- spec/requests/api/groups_spec.rb | 7 +- spec/requests/api/internal/base_spec.rb | 2 +- spec/requests/api/invitations_spec.rb | 32 + spec/requests/api/lint_spec.rb | 123 +- spec/requests/api/members_spec.rb | 54 +- spec/requests/api/merge_requests_spec.rb | 2 + spec/requests/api/namespaces_spec.rb | 76 +- spec/requests/api/npm_project_packages_spec.rb | 20 + spec/requests/api/project_attributes.yml | 1 + .../api/project_debian_distributions_spec.rb | 22 +- spec/requests/api/project_import_spec.rb | 2 +- spec/requests/api/project_snapshots_spec.rb | 1 + spec/requests/api/project_snippets_spec.rb | 1 + spec/requests/api/projects_spec.rb | 31 +- spec/requests/api/releases_spec.rb | 32 + spec/requests/api/repositories_spec.rb | 39 + spec/requests/api/settings_spec.rb | 41 + spec/requests/api/snippets_spec.rb | 1 + spec/requests/api/tags_spec.rb | 67 + .../api/terraform/modules/v1/packages_spec.rb | 17 +- spec/requests/api/todos_spec.rb | 12 +- spec/requests/api/topics_spec.rb | 217 ++++ spec/requests/api/users_spec.rb | 8 +- spec/requests/api/v3/github_spec.rb | 131 +- .../groups/email_campaigns_controller_spec.rb | 10 +- .../settings/applications_controller_spec.rb | 20 + .../import/gitlab_groups_controller_spec.rb | 2 +- spec/requests/jwks_controller_spec.rb | 14 + .../requests/oauth/applications_controller_spec.rb | 18 + .../projects/google_cloud_controller_spec.rb | 94 +- spec/requests/projects/issues/discussions_spec.rb | 115 ++ spec/requests/projects/issues_controller_spec.rb | 71 + spec/requests/projects/usage_quotas_spec.rb | 50 +- spec/requests/rack_attack_global_spec.rb | 4 + spec/requests/users_controller_spec.rb | 2 +- spec/routing/group_routing_spec.rb | 20 + spec/routing/openid_connect_spec.rb | 12 +- spec/rubocop/cop/gitlab/bulk_insert_spec.rb | 12 +- spec/rubocop/cop/gitlab/change_timezone_spec.rb | 2 +- .../rubocop/cop/qa/duplicate_testcase_link_spec.rb | 36 + spec/scripts/changed-feature-flags_spec.rb | 79 ++ spec/scripts/failed_tests_spec.rb | 127 ++ spec/scripts/pipeline_test_report_builder_spec.rb | 185 +++ .../analytics_summary_serializer_spec.rb | 2 +- spec/serializers/merge_request_user_entity_spec.rb | 9 +- .../merge_request_widget_entity_spec.rb | 33 +- spec/serializers/service_field_entity_spec.rb | 14 +- .../admin/propagate_integration_service_spec.rb | 4 +- .../project_access_changed_service_spec.rb | 21 + .../merge_when_pipeline_succeeds_service_spec.rb | 4 + spec/services/award_emojis/base_service_spec.rb | 25 + .../bulk_create_integration_service_spec.rb | 21 +- .../bulk_update_integration_service_spec.rb | 41 +- .../ci/create_pipeline_service/include_spec.rb | 89 +- spec/services/ci/create_pipeline_service_spec.rb | 4 +- .../create_pipeline_service_spec.rb | 22 - .../ci/generate_kubeconfig_service_spec.rb | 50 + .../ci/job_artifacts/create_service_spec.rb | 14 +- .../destroy_all_expired_service_spec.rb | 63 +- .../ci/job_artifacts/destroy_batch_service_spec.rb | 3 +- .../ci/parse_dotenv_artifact_service_spec.rb | 6 +- spec/services/ci/retry_build_service_spec.rb | 31 + spec/services/ci/unlock_artifacts_service_spec.rb | 280 +++- .../services/ci/update_build_state_service_spec.rb | 22 +- .../agents/refresh_authorization_service_spec.rb | 10 + .../prometheus_health_check_service_spec.rb | 114 -- .../cleanup/project_namespace_service_spec.rb | 13 + .../cleanup/service_account_service_spec.rb | 8 + .../prometheus_health_check_service_spec.rb | 114 ++ .../find_or_create_blob_service_spec.rb | 2 +- .../find_or_create_manifest_service_spec.rb | 66 +- .../dependency_proxy/head_manifest_service_spec.rb | 2 +- .../dependency_proxy/pull_manifest_service_spec.rb | 2 +- .../deployments/archive_in_project_service_spec.rb | 80 ++ .../link_merge_requests_service_spec.rb | 13 + spec/services/emails/create_service_spec.rb | 5 +- spec/services/emails/destroy_service_spec.rb | 10 + .../error_tracking/collect_error_service_spec.rb | 31 +- .../google_cloud/service_accounts_service_spec.rb | 58 + spec/services/groups/create_service_spec.rb | 41 +- .../groups/import_export/import_service_spec.rb | 6 + spec/services/groups/transfer_service_spec.rb | 104 +- .../import/github/notes/create_service_spec.rb | 24 + spec/services/issues/build_service_spec.rb | 96 +- spec/services/issues/close_service_spec.rb | 44 +- spec/services/issues/create_service_spec.rb | 79 +- .../issues/set_crm_contacts_service_spec.rb | 162 +++ spec/services/issues/update_service_spec.rb | 42 +- spec/services/labels/transfer_service_spec.rb | 156 ++- .../batch_cleaner_service_spec.rb | 119 ++ .../loose_foreign_keys/cleaner_service_spec.rb | 147 +++ spec/services/members/create_service_spec.rb | 104 ++ spec/services/members/invite_service_spec.rb | 5 + .../mergeability/run_checks_service_spec.rb | 4 +- .../merge_requests/retarget_chain_service_spec.rb | 8 - .../toggle_attention_requested_service_spec.rb | 128 ++ .../in_product_marketing_email_records_spec.rb | 55 + .../namespaces/invite_team_email_service_spec.rb | 128 ++ spec/services/notification_service_spec.rb | 24 +- .../packages/create_dependency_service_spec.rb | 6 +- .../packages/npm/create_package_service_spec.rb | 46 + spec/services/packages/update_tags_service_spec.rb | 2 +- .../projects/all_issues_count_service_spec.rb | 24 + .../all_merge_requests_count_service_spec.rb | 30 + .../cache_tags_created_at_service_spec.rb | 133 -- .../cleanup_tags_service_spec.rb | 457 +++---- spec/services/projects/create_service_spec.rb | 35 +- spec/services/projects/destroy_service_spec.rb | 8 + .../projects/import_export/export_service_spec.rb | 12 +- .../lfs_pointers/lfs_download_service_spec.rb | 1 + .../services/projects/participants_service_spec.rb | 8 - .../prometheus/alerts/notify_service_spec.rb | 3 +- spec/services/projects/transfer_service_spec.rb | 8 +- .../quick_actions/interpret_service_spec.rb | 53 +- .../resource_events/change_labels_service_spec.rb | 2 +- .../synthetic_label_notes_builder_service_spec.rb | 12 +- ...nthetic_milestone_notes_builder_service_spec.rb | 2 + .../synthetic_state_notes_builder_service_spec.rb | 11 + .../sast_iac_create_service_spec.rb | 19 + spec/services/spam/spam_verdict_service_spec.rb | 4 +- spec/services/system_note_service_spec.rb | 199 +-- .../services/system_notes/incident_service_spec.rb | 10 + .../system_notes/issuables_service_spec.rb | 17 + .../services/tasks_to_be_done/base_service_spec.rb | 69 + spec/services/todo_service_spec.rb | 11 + spec/services/users/update_service_spec.rb | 2 +- .../upsert_credit_card_validation_service_spec.rb | 13 +- spec/sidekiq_cluster/sidekiq_cluster_spec.rb | 208 +++ spec/spec_helper.rb | 20 +- spec/support/capybara.rb | 4 +- .../cross-database-modification-allowlist.yml | 1259 +----------------- spec/support/database/cross-join-allowlist.yml | 54 +- spec/support/database/gitlab_schema.rb | 25 - spec/support/database/multiple_databases.rb | 27 + .../prevent_cross_database_modification.rb | 122 +- spec/support/database/prevent_cross_joins.rb | 4 +- spec/support/database/query_analyzer.rb | 14 + spec/support/database_load_balancing.rb | 16 +- spec/support/flaky_tests.rb | 36 + .../metrics_instrumentation_shared_examples.rb | 4 +- spec/support/graphql/fake_query_type.rb | 15 + spec/support/graphql/fake_tracer.rb | 15 + spec/support/helpers/cycle_analytics_helpers.rb | 4 + .../features/invite_members_modal_helper.rb | 2 +- spec/support/helpers/gitaly_setup.rb | 2 +- spec/support/helpers/gpg_helpers.rb | 1 + spec/support/helpers/graphql_helpers.rb | 3 +- spec/support/helpers/migrations_helpers.rb | 6 +- spec/support/helpers/navbar_structure_helper.rb | 13 + spec/support/helpers/project_forks_helper.rb | 6 +- spec/support/helpers/require_migration.rb | 6 +- spec/support/helpers/stub_gitlab_calls.rb | 9 +- spec/support/helpers/stub_object_storage.rb | 12 +- spec/support/helpers/test_env.rb | 4 +- spec/support/helpers/usage_data_helpers.rb | 2 + spec/support/helpers/workhorse_helpers.rb | 7 +- spec/support/matchers/access_matchers.rb | 2 +- spec/support/matchers/project_namespace_matcher.rb | 28 + .../patches/rspec_example_prepended_methods.rb | 26 + spec/support/redis/redis_shared_examples.rb | 37 + spec/support/retriable.rb | 7 + .../graphql/requests/packages_shared_context.rb | 6 + .../background_migration_job_shared_context.rb | 21 - .../shared_contexts/navbar_structure_context.rb | 2 +- .../policies/project_policy_shared_context.rb | 11 +- .../api/debian_repository_shared_context.rb | 120 ++ .../delete_tags_service_shared_context.rb | 4 +- ...vice_ping_metrics_definitions_shared_context.rb | 5 + spec/support/shared_contexts/url_shared_context.rb | 39 +- .../common/pipelines/wiki_pipeline_examples.rb | 31 + .../integrations_actions_shared_examples.rb | 59 + .../integrations_actions_shared_examples.rb | 59 - .../create_notes_rate_limit_shared_examples.rb | 58 +- .../features/2fa_shared_examples.rb | 1 + .../features/dependency_proxy_shared_examples.rb | 2 +- .../manage_applications_shared_examples.rb | 2 + .../features/packages_shared_examples.rb | 30 +- ...olving_discussions_in_issues_shared_examples.rb | 38 +- .../features/sidebar_shared_examples.rb | 11 +- .../graphql/notes_creation_shared_examples.rb | 26 +- .../lib/gitlab/ci/ci_trace_shared_examples.rb | 67 +- .../gitlab/cycle_analytics/deployment_metrics.rb | 2 +- .../database/cte_materialized_shared_examples.rb | 6 +- .../attributes_permitter_shared_examples.rb | 4 +- .../sidekiq_middleware/strategy_shared_examples.rb | 44 +- .../projects/menus/zentao_menu_shared_examples.rb | 42 + .../loose_foreign_keys/have_loose_foreign_key.rb | 52 + ...nsaction_metrics_with_labels_shared_examples.rb | 219 ++++ .../cycle_analytics/stage_event_model_examples.rb | 117 +- .../concerns/ttl_expirable_shared_examples.rb | 15 +- .../models/member_shared_examples.rb | 31 + .../models/reviewer_state_shared_examples.rb | 15 + .../namespaces/traversal_examples.rb | 22 + .../namespaces/traversal_scope_examples.rb | 81 +- ...ote_to_incident_quick_action_shared_examples.rb | 40 + .../requests/api/debian_common_shared_examples.rb | 17 + .../api/debian_distributions_shared_examples.rb | 192 +++ .../api/debian_packages_shared_examples.rb | 369 +----- .../mutations/destroy_list_shared_examples.rb | 2 +- .../packages/package_details_shared_examples.rb | 18 +- .../requests/api/notes_shared_examples.rb | 2 +- .../requests/api/npm_packages_shared_examples.rb | 15 + .../requests/api/pypi_packages_shared_examples.rb | 4 +- .../requests/api/status_shared_examples.rb | 29 + .../applications_controller_shared_examples.rb | 44 + .../requests/self_monitoring_shared_examples.rb | 4 + .../requests/snippet_shared_examples.rb | 1 + .../service_desk_issue_templates_examples.rb | 8 +- .../alert_firing_shared_examples.rb | 6 +- .../services/jira/requests/base_shared_examples.rb | 11 +- .../synthetic_notes_builder_shared_examples.rb | 25 + .../workers/self_monitoring_shared_examples.rb | 2 +- spec/support/stub_snowplow.rb | 2 - spec/support/test_reports/test_reports_helper.rb | 6 +- spec/support/time_travel.rb | 21 + .../database/multiple_databases_spec.rb | 6 +- .../prevent_cross_database_modification_spec.rb | 163 --- .../helpers/stub_feature_flags_spec.rb | 2 +- spec/support_specs/time_travel_spec.rb | 21 + spec/tasks/gitlab/db_rake_spec.rb | 46 +- spec/tasks/gitlab/gitaly_rake_spec.rb | 35 +- spec/tasks/gitlab/storage_rake_spec.rb | 4 +- spec/tooling/danger/changelog_spec.rb | 4 +- spec/tooling/danger/product_intelligence_spec.rb | 83 +- spec/tooling/danger/project_helper_spec.rb | 128 +- spec/tooling/quality/test_level_spec.rb | 12 +- spec/validators/addressable_url_validator_spec.rb | 12 + .../groups/settings/_remove.html.haml_spec.rb | 4 +- .../groups/settings/_transfer.html.haml_spec.rb | 6 +- .../subscriptions/index.html.haml_spec.rb | 2 +- .../_published_experiments.html.haml_spec.rb | 35 + .../layouts/nav/sidebar/_project.html.haml_spec.rb | 24 +- spec/views/profiles/audit_log.html.haml_spec.rb | 26 + spec/views/projects/edit.html.haml_spec.rb | 35 + .../_service_desk_info_content.html.haml_spec.rb | 95 ++ .../usage_trends/counter_job_worker_spec.rb | 3 +- .../ci/ref_delete_unlock_artifacts_worker_spec.rb | 34 +- ...ign_resource_from_resource_group_worker_spec.rb | 4 + .../check_prometheus_health_worker_spec.rb | 19 - .../check_prometheus_health_worker_spec.rb | 19 + spec/workers/concerns/application_worker_spec.rb | 381 +++++- .../cleanup_container_repository_worker_spec.rb | 5 +- .../drop_detached_partitions_worker_spec.rb | 8 +- .../database/partition_management_worker_spec.rb | 9 +- .../image_ttl_group_policy_worker_spec.rb | 4 +- .../deployments/archive_in_project_worker_spec.rb | 18 + spec/workers/email_receiver_worker_spec.rb | 9 + spec/workers/emails_on_push_worker_spec.rb | 37 + spec/workers/every_sidekiq_worker_spec.rb | 2 + .../create_external_cross_reference_worker_spec.rb | 128 ++ spec/workers/issue_rebalancing_worker_spec.rb | 16 +- spec/workers/issues/placement_worker_spec.rb | 151 +++ spec/workers/issues/rebalancing_worker_spec.rb | 90 ++ ...eschedule_stuck_issue_rebalances_worker_spec.rb | 26 + .../loose_foreign_keys/cleanup_worker_spec.rb | 153 +++ .../namespaces/invite_team_email_worker_spec.rb | 27 + .../packages/maven/metadata/sync_worker_spec.rb | 3 +- spec/workers/post_receive_spec.rb | 8 - .../propagate_integration_group_worker_spec.rb | 2 +- ...e_integration_inherit_descendant_worker_spec.rb | 4 +- .../propagate_integration_project_worker_spec.rb | 2 +- .../ssh_keys/expired_notification_worker_spec.rb | 6 +- .../workers/tasks_to_be_done/create_worker_spec.rb | 36 + .../users/deactivate_dormant_users_worker_spec.rb | 36 +- 1580 files changed, 45732 insertions(+), 29745 deletions(-) create mode 100644 spec/commands/sidekiq_cluster/cli_spec.rb create mode 100644 spec/experiments/change_continuous_onboarding_link_urls_experiment_spec.rb delete mode 100644 spec/experiments/empty_repo_upload_experiment_spec.rb create mode 100644 spec/factories/analytics/cycle_analytics/issue_stage_events.rb create mode 100644 spec/factories/analytics/cycle_analytics/merge_request_stage_events.rb create mode 100644 spec/factories/customer_relations/issue_customer_relations_contacts.rb create mode 100644 spec/factories/gitlab/database/reindexing/queued_action.rb create mode 100644 spec/factories/member_tasks.rb create mode 100644 spec/factories/packages/npm/metadata.rb create mode 100644 spec/features/clusters/create_agent_spec.rb create mode 100644 spec/features/explore/topics_spec.rb create mode 100644 spec/features/graphql_known_operations_spec.rb create mode 100644 spec/features/issue_rebalancing_spec.rb create mode 100644 spec/features/projects/cluster_agents_spec.rb create mode 100644 spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb create mode 100644 spec/features/topic_show_spec.rb create mode 100644 spec/features/users/confirmation_spec.rb create mode 100644 spec/features/users/password_spec.rb create mode 100644 spec/finders/autocomplete/routes_finder_spec.rb create mode 100644 spec/finders/clusters/agent_authorizations_finder_spec.rb create mode 100644 spec/fixtures/api/schemas/public_api/v4/deploy_key.json create mode 100644 spec/fixtures/api/schemas/public_api/v4/deploy_keys.json delete mode 100644 spec/fixtures/bulk_imports/gz/milestones.ndjson.gz delete mode 100644 spec/fixtures/bulk_imports/milestones.ndjson create mode 100644 spec/fixtures/emails/service_desk_all_quoted.eml create mode 100644 spec/fixtures/emails/service_desk_custom_address_no_key.eml create mode 100644 spec/fixtures/error_tracking/browser_event.json create mode 100644 spec/fixtures/error_tracking/go_parsed_event.json create mode 100644 spec/fixtures/error_tracking/python_event.json create mode 100644 spec/fixtures/scripts/test_report.json create mode 100644 spec/frontend/admin/deploy_keys/components/table_spec.js delete mode 100644 spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js create mode 100644 spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js create mode 100644 spec/frontend/boards/components/new_board_button_spec.js create mode 100644 spec/frontend/chronic_duration_spec.js create mode 100644 spec/frontend/clusters_list/components/clusters_actions_spec.js create mode 100644 spec/frontend/clusters_list/components/clusters_empty_state_spec.js create mode 100644 spec/frontend/clusters_list/components/clusters_main_view_spec.js create mode 100644 spec/frontend/clusters_list/components/clusters_view_all_spec.js create mode 100644 spec/frontend/content_editor/components/content_editor_alert_spec.js delete mode 100644 spec/frontend/content_editor/components/content_editor_error_spec.js create mode 100644 spec/frontend/content_editor/extensions/frontmatter_spec.js create mode 100644 spec/frontend/content_editor/extensions/table_spec.js create mode 100644 spec/frontend/content_editor/extensions/word_break_spec.js create mode 100644 spec/frontend/crm/contacts_root_spec.js create mode 100644 spec/frontend/crm/mock_data.js create mode 100644 spec/frontend/crm/organizations_root_spec.js create mode 100644 spec/frontend/cycle_analytics/metric_popover_spec.js create mode 100644 spec/frontend/diffs/utils/diff_line_spec.js create mode 100644 spec/frontend/diffs/utils/discussions_spec.js create mode 100644 spec/frontend/editor/helpers.js create mode 100644 spec/frontend/editor/source_editor_extension_spec.js create mode 100644 spec/frontend/editor/source_editor_instance_spec.js create mode 100644 spec/frontend/editor/source_editor_yaml_ext_spec.js create mode 100644 spec/frontend/environments/graphql/mock_data.js create mode 100644 spec/frontend/environments/graphql/resolvers_spec.js create mode 100644 spec/frontend/environments/new_environment_folder_spec.js create mode 100644 spec/frontend/environments/new_environments_app_spec.js create mode 100644 spec/frontend/google_cloud/components/app_spec.js create mode 100644 spec/frontend/google_cloud/components/incubation_banner_spec.js create mode 100644 spec/frontend/google_cloud/components/service_accounts_spec.js create mode 100644 spec/frontend/ide/components/shared/commit_message_field_spec.js create mode 100644 spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js delete mode 100644 spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js delete mode 100644 spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js create mode 100644 spec/frontend/import_entities/import_groups/services/status_poller_spec.js create mode 100644 spec/frontend/invite_members/components/confetti_spec.js create mode 100644 spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js create mode 100644 spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js create mode 100644 spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js create mode 100644 spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js delete mode 100644 spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js delete mode 100644 spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js create mode 100644 spec/frontend/jira_connect/subscriptions/components/sign_in_button_spec.js create mode 100644 spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js delete mode 100644 spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap delete mode 100644 spec/frontend/monitoring/alert_widget_spec.js delete mode 100644 spec/frontend/monitoring/components/alert_widget_form_spec.js delete mode 100644 spec/frontend/packages/list/components/packages_search_spec.js delete mode 100644 spec/frontend/packages/list/components/packages_title_spec.js delete mode 100644 spec/frontend/packages/list/components/tokens/package_type_token_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js create mode 100644 spec/frontend/packages_and_registries/container_registry/explorer/stubs.js create mode 100644 spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js create mode 100644 spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js create mode 100644 spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js create mode 100644 spec/frontend/packages_and_registries/shared/mocks.js create mode 100644 spec/frontend/packages_and_registries/shared/stubs.js create mode 100644 spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js create mode 100644 spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js create mode 100644 spec/frontend/projects/settings_service_desk/components/mock_data.js create mode 100644 spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js create mode 100644 spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js delete mode 100644 spec/frontend/projects/upload_file_experiment_tracking_spec.js delete mode 100644 spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap delete mode 100644 spec/frontend/registry/explorer/components/delete_button_spec.js delete mode 100644 spec/frontend/registry/explorer/components/delete_image_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap delete mode 100644 spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/details_header_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/empty_state_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/status_alert_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/tags_list_spec.js delete mode 100644 spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js delete mode 100644 spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap delete mode 100644 spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap delete mode 100644 spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js delete mode 100644 spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js delete mode 100644 spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js delete mode 100644 spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js delete mode 100644 spec/frontend/registry/explorer/components/list_page/image_list_spec.js delete mode 100644 spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js delete mode 100644 spec/frontend/registry/explorer/components/list_page/registry_header_spec.js delete mode 100644 spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js delete mode 100644 spec/frontend/registry/explorer/mock_data.js delete mode 100644 spec/frontend/registry/explorer/pages/details_spec.js delete mode 100644 spec/frontend/registry/explorer/pages/index_spec.js delete mode 100644 spec/frontend/registry/explorer/pages/list_spec.js delete mode 100644 spec/frontend/registry/explorer/stubs.js delete mode 100644 spec/frontend/registry/shared/mocks.js delete mode 100644 spec/frontend/registry/shared/stubs.js create mode 100644 spec/frontend/repository/mock_data.js create mode 100644 spec/frontend/runner/components/cells/runner_status_cell_spec.js delete mode 100644 spec/frontend/runner/components/cells/runner_type_cell_spec.js delete mode 100644 spec/frontend/runner/components/helpers/masked_value_spec.js create mode 100644 spec/frontend/runner/components/registration/registration_dropdown_spec.js create mode 100644 spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js create mode 100644 spec/frontend/runner/components/registration/registration_token_spec.js create mode 100644 spec/frontend/runner/components/runner_contacted_state_badge_spec.js delete mode 100644 spec/frontend/runner/components/runner_manual_setup_help_spec.js create mode 100644 spec/frontend/runner/components/runner_paused_badge_spec.js delete mode 100644 spec/frontend/runner/components/runner_registration_token_reset_spec.js delete mode 100644 spec/frontend/runner/components/runner_state_locked_badge_spec.js delete mode 100644 spec/frontend/runner/components/runner_state_paused_badge_spec.js create mode 100644 spec/frontend/runner/components/runner_type_tabs_spec.js create mode 100644 spec/frontend/sidebar/components/attention_required_toggle_spec.js create mode 100644 spec/frontend/terms/components/app_spec.js delete mode 100644 spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js create mode 100644 spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js create mode 100644 spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js create mode 100644 spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js create mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js create mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js create mode 100644 spec/frontend/work_items/components/app_spec.js create mode 100644 spec/frontend/work_items/mock_data.js create mode 100644 spec/frontend/work_items/pages/work_item_root_spec.js create mode 100644 spec/frontend/work_items/router_spec.js delete mode 100644 spec/graphql/mutations/merge_requests/set_wip_spec.rb create mode 100644 spec/graphql/mutations/security/ci_configuration/configure_sast_iac_spec.rb create mode 100644 spec/graphql/resolvers/concerns/resolves_groups_spec.rb create mode 100644 spec/graphql/resolvers/topics_resolver_spec.rb create mode 100644 spec/graphql/types/ci/pipeline_scope_enum_spec.rb create mode 100644 spec/graphql/types/ci/pipeline_status_enum_spec.rb create mode 100644 spec/graphql/types/packages/helm/dependency_type_spec.rb create mode 100644 spec/graphql/types/packages/helm/file_metadatum_type_spec.rb create mode 100644 spec/graphql/types/packages/helm/maintainer_type_spec.rb create mode 100644 spec/graphql/types/packages/helm/metadata_type_spec.rb create mode 100644 spec/graphql/types/projects/topic_type_spec.rb create mode 100644 spec/helpers/admin/deploy_key_helper_spec.rb create mode 100644 spec/helpers/groups/settings_helper_spec.rb create mode 100644 spec/helpers/terms_helper_spec.rb create mode 100644 spec/initializers/0_postgresql_types_spec.rb create mode 100644 spec/initializers/session_store_spec.rb create mode 100644 spec/lib/api/entities/projects/topic_spec.rb create mode 100644 spec/lib/bulk_imports/common/pipelines/milestones_pipeline_spec.rb create mode 100644 spec/lib/bulk_imports/common/pipelines/uploads_pipeline_spec.rb create mode 100644 spec/lib/bulk_imports/common/pipelines/wiki_pipeline_spec.rb delete mode 100644 spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb delete mode 100644 spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb create mode 100644 spec/lib/bulk_imports/projects/pipelines/external_pull_requests_pipeline_spec.rb create mode 100644 spec/lib/bulk_imports/projects/pipelines/merge_requests_pipeline_spec.rb create mode 100644 spec/lib/bulk_imports/projects/pipelines/protected_branches_pipeline_spec.rb create mode 100644 spec/lib/error_tracking/collector/payload_validator_spec.rb create mode 100644 spec/lib/gitlab/analytics/cycle_analytics/aggregated/base_query_builder_spec.rb create mode 100644 spec/lib/gitlab/analytics/cycle_analytics/aggregated/records_fetcher_spec.rb create mode 100644 spec/lib/gitlab/background_migration/add_primary_email_to_emails_if_user_confirmed_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/backfill_design_internal_ids_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/copy_merge_request_target_project_to_merge_request_metrics_spec.rb create mode 100644 spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/fix_projects_without_project_feature_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/fix_projects_without_prometheus_service_spec.rb create mode 100644 spec/lib/gitlab/background_migration/job_coordinator_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/populate_user_highest_roles_table_spec.rb create mode 100644 spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/set_null_external_diff_store_to_local_value_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/set_null_package_files_file_store_to_local_value_spec.rb create mode 100644 spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb create mode 100644 spec/lib/gitlab/ci/templates/kaniko_gitlab_ci_yaml_spec.rb create mode 100644 spec/lib/gitlab/ci/variables/builder_spec.rb create mode 100644 spec/lib/gitlab/container_repository/tags/cache_spec.rb delete mode 100644 spec/lib/gitlab/database/connection_spec.rb create mode 100644 spec/lib/gitlab/database/each_database_spec.rb create mode 100644 spec/lib/gitlab/database/gitlab_schema_spec.rb create mode 100644 spec/lib/gitlab/database/migrations/observers/transaction_duration_spec.rb delete mode 100644 spec/lib/gitlab/database/partitioning/multi_database_partition_dropper_spec.rb delete mode 100644 spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb create mode 100644 spec/lib/gitlab/database/query_analyzer_spec.rb create mode 100644 spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb create mode 100644 spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb create mode 100644 spec/lib/gitlab/database/reflection_spec.rb create mode 100644 spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb create mode 100644 spec/lib/gitlab/grape_logging/loggers/urgency_logger_spec.rb create mode 100644 spec/lib/gitlab/graphql/known_operations_spec.rb create mode 100644 spec/lib/gitlab/graphql/tracers/application_context_tracer_spec.rb create mode 100644 spec/lib/gitlab/graphql/tracers/logger_tracer_spec.rb create mode 100644 spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb create mode 100644 spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb create mode 100644 spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb create mode 100644 spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb delete mode 100644 spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb create mode 100644 spec/lib/gitlab/merge_requests/merge_commit_message_spec.rb create mode 100644 spec/lib/gitlab/middleware/compressed_json_spec.rb create mode 100644 spec/lib/gitlab/middleware/query_analyzer_spec.rb create mode 100644 spec/lib/gitlab/redis/multi_store_spec.rb delete mode 100644 spec/lib/gitlab/sidekiq_cluster/cli_spec.rb delete mode 100644 spec/lib/gitlab/sidekiq_cluster_spec.rb create mode 100644 spec/lib/gitlab/sidekiq_enq_spec.rb create mode 100644 spec/lib/gitlab/sidekiq_middleware/query_analyzer_spec.rb delete mode 100644 spec/lib/gitlab/tracking/destinations/product_analytics_spec.rb create mode 100644 spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb delete mode 100644 spec/lib/gitlab/usage_data_counters/vs_code_extenion_activity_unique_counter_spec.rb create mode 100644 spec/lib/gitlab/usage_data_counters/vscode_extenion_activity_unique_counter_spec.rb create mode 100644 spec/lib/gitlab/webpack/file_loader_spec.rb create mode 100644 spec/lib/gitlab/webpack/graphql_known_operations_spec.rb create mode 100644 spec/lib/gitlab/zentao/query_spec.rb create mode 100644 spec/lib/security/ci_configuration/sast_iac_build_action_spec.rb create mode 100644 spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb create mode 100644 spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb create mode 100644 spec/lib/sidebars/projects/menus/zentao_menu_spec.rb delete mode 100644 spec/migrations/20200107172020_add_timestamp_softwarelicensespolicy_spec.rb delete mode 100644 spec/migrations/20200122123016_backfill_project_settings_spec.rb delete mode 100644 spec/migrations/20200123155929_remove_invalid_jira_data_spec.rb delete mode 100644 spec/migrations/20200127090233_remove_invalid_issue_tracker_data_spec.rb delete mode 100644 spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb delete mode 100644 spec/migrations/20200313203550_remove_orphaned_chat_names_spec.rb delete mode 100644 spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb delete mode 100644 spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb delete mode 100644 spec/migrations/20200526115436_dedup_mr_metrics_spec.rb delete mode 100644 spec/migrations/20200526231421_update_index_approval_rule_name_for_code_owners_rule_type_spec.rb delete mode 100644 spec/migrations/20200703125016_backfill_namespace_settings_spec.rb delete mode 100644 spec/migrations/20200706035141_adjust_unique_index_alert_management_alerts_spec.rb delete mode 100644 spec/migrations/20200728080250_replace_unique_index_on_cycle_analytics_stages_spec.rb delete mode 100644 spec/migrations/20200728182311_add_o_auth_paths_to_protected_paths_spec.rb delete mode 100644 spec/migrations/20200811130433_create_missing_vulnerabilities_issue_links_spec.rb delete mode 100644 spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb delete mode 100644 spec/migrations/20200929052138_create_initial_versions_for_pre_versioning_terraform_states_spec.rb delete mode 100644 spec/migrations/20201014205300_drop_backfill_jira_tracker_deployment_type_jobs_spec.rb delete mode 100644 spec/migrations/20201027002551_migrate_services_to_http_integrations_spec.rb delete mode 100644 spec/migrations/20201028182809_backfill_jira_tracker_deployment_type2_spec.rb delete mode 100644 spec/migrations/20201110161542_cleanup_transfered_projects_shared_runners_spec.rb delete mode 100644 spec/migrations/20201112130710_schedule_remove_duplicate_vulnerabilities_findings_spec.rb delete mode 100644 spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb create mode 100644 spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb create mode 100644 spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb create mode 100644 spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb delete mode 100644 spec/migrations/add_default_value_stream_to_groups_with_group_stages_spec.rb delete mode 100644 spec/migrations/add_deploy_token_type_to_deploy_tokens_spec.rb delete mode 100644 spec/migrations/add_incident_settings_to_all_existing_projects_spec.rb create mode 100644 spec/migrations/add_open_source_plan_spec.rb delete mode 100644 spec/migrations/add_partial_index_to_ci_builds_table_on_user_id_name_spec.rb delete mode 100644 spec/migrations/add_repository_storages_weighted_to_application_settings_spec.rb delete mode 100644 spec/migrations/add_temporary_partial_index_on_project_id_to_services_spec.rb delete mode 100644 spec/migrations/backfill_imported_snippet_repositories_spec.rb delete mode 100644 spec/migrations/backfill_operations_feature_flags_iid_spec.rb delete mode 100644 spec/migrations/backfill_snippet_repositories_spec.rb delete mode 100644 spec/migrations/backfill_status_page_published_incidents_spec.rb create mode 100644 spec/migrations/backfill_user_namespace_spec.rb delete mode 100644 spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb delete mode 100644 spec/migrations/clean_grafana_url_spec.rb delete mode 100644 spec/migrations/cleanup_empty_commit_user_mentions_spec.rb delete mode 100644 spec/migrations/cleanup_group_import_states_with_null_user_id_spec.rb create mode 100644 spec/migrations/cleanup_move_container_registry_enabled_to_project_feature_spec.rb delete mode 100644 spec/migrations/cleanup_move_container_registry_enabled_to_project_features_spec.rb delete mode 100644 spec/migrations/cleanup_optimistic_locking_nulls_pt2_fixed_spec.rb delete mode 100644 spec/migrations/cleanup_optimistic_locking_nulls_spec.rb delete mode 100644 spec/migrations/cleanup_projects_with_missing_namespace_spec.rb delete mode 100644 spec/migrations/complete_namespace_settings_migration_spec.rb delete mode 100644 spec/migrations/confirm_project_bot_users_spec.rb delete mode 100644 spec/migrations/create_environment_for_self_monitoring_project_spec.rb delete mode 100644 spec/migrations/deduplicate_epic_iids_spec.rb delete mode 100644 spec/migrations/delete_internal_ids_where_feature_flags_usage_spec.rb delete mode 100644 spec/migrations/delete_template_project_services_spec.rb delete mode 100644 spec/migrations/delete_template_services_duplicated_by_type_spec.rb delete mode 100644 spec/migrations/delete_user_callout_alerts_moved_spec.rb delete mode 100644 spec/migrations/drop_activate_prometheus_services_background_jobs_spec.rb delete mode 100644 spec/migrations/drop_background_migration_jobs_spec.rb delete mode 100644 spec/migrations/ensure_filled_external_diff_store_on_merge_request_diffs_spec.rb delete mode 100644 spec/migrations/ensure_filled_file_store_on_package_files_spec.rb delete mode 100644 spec/migrations/ensure_namespace_settings_creation_spec.rb delete mode 100644 spec/migrations/ensure_target_project_id_is_filled_spec.rb delete mode 100644 spec/migrations/ensure_u2f_registrations_migrated_spec.rb delete mode 100644 spec/migrations/fill_file_store_ci_job_artifacts_spec.rb delete mode 100644 spec/migrations/fill_file_store_lfs_objects_spec.rb delete mode 100644 spec/migrations/fill_store_uploads_spec.rb delete mode 100644 spec/migrations/fix_projects_without_project_feature_spec.rb delete mode 100644 spec/migrations/fix_projects_without_prometheus_services_spec.rb delete mode 100644 spec/migrations/generate_ci_jwt_signing_key_spec.rb delete mode 100644 spec/migrations/generate_missing_routes_for_bots_spec.rb delete mode 100644 spec/migrations/insert_daily_invites_plan_limits_spec.rb delete mode 100644 spec/migrations/insert_project_feature_flags_plan_limits_spec.rb delete mode 100644 spec/migrations/migrate_all_merge_request_user_mentions_to_db_spec.rb delete mode 100644 spec/migrations/migrate_bot_type_to_user_type_spec.rb delete mode 100644 spec/migrations/migrate_commit_notes_mentions_to_db_spec.rb delete mode 100644 spec/migrations/migrate_compliance_framework_enum_to_database_framework_record_spec.rb delete mode 100644 spec/migrations/migrate_create_commit_signature_worker_sidekiq_queue_spec.rb delete mode 100644 spec/migrations/migrate_incident_issues_to_incident_type_spec.rb delete mode 100644 spec/migrations/migrate_merge_request_mentions_to_db_spec.rb delete mode 100644 spec/migrations/migrate_store_security_reports_sidekiq_queue_spec.rb delete mode 100644 spec/migrations/migrate_sync_security_reports_to_report_approval_rules_sidekiq_queue_spec.rb delete mode 100644 spec/migrations/populate_remaining_missing_dismissal_information_for_vulnerabilities_spec.rb delete mode 100644 spec/migrations/remove_additional_application_settings_rows_spec.rb delete mode 100644 spec/migrations/remove_deprecated_jenkins_service_records_spec.rb delete mode 100644 spec/migrations/remove_duplicate_labels_from_groups_spec.rb delete mode 100644 spec/migrations/remove_duplicate_labels_from_project_spec.rb delete mode 100644 spec/migrations/remove_gitlab_issue_tracker_service_records_spec.rb delete mode 100644 spec/migrations/remove_orphan_service_hooks_spec.rb delete mode 100644 spec/migrations/remove_orphaned_invited_members_spec.rb delete mode 100644 spec/migrations/remove_packages_deprecated_dependencies_spec.rb delete mode 100644 spec/migrations/remove_security_dashboard_feature_flag_spec.rb delete mode 100644 spec/migrations/rename_security_dashboard_feature_flag_to_instance_security_dashboard_spec.rb delete mode 100644 spec/migrations/rename_sitemap_namespace_spec.rb delete mode 100644 spec/migrations/rename_sitemap_root_namespaces_spec.rb delete mode 100644 spec/migrations/reschedule_set_default_iteration_cadences_spec.rb delete mode 100644 spec/migrations/reseed_merge_trains_enabled_spec.rb delete mode 100644 spec/migrations/reseed_repository_storages_weighted_spec.rb delete mode 100644 spec/migrations/save_instance_administrators_group_id_spec.rb create mode 100644 spec/migrations/schedule_add_primary_email_to_emails_if_user_confirmed_spec.rb delete mode 100644 spec/migrations/schedule_backfill_push_rules_id_in_projects_spec.rb delete mode 100644 spec/migrations/schedule_blocked_by_links_replacement_second_try_spec.rb delete mode 100644 spec/migrations/schedule_link_lfs_objects_projects_spec.rb delete mode 100644 spec/migrations/schedule_merge_request_cleanup_schedules_backfill_spec.rb delete mode 100644 spec/migrations/schedule_migrate_security_scans_spec.rb delete mode 100644 spec/migrations/schedule_migrate_u2f_webauthn_spec.rb delete mode 100644 spec/migrations/schedule_populate_has_vulnerabilities_spec.rb delete mode 100644 spec/migrations/schedule_populate_issue_email_participants_spec.rb delete mode 100644 spec/migrations/schedule_populate_missing_dismissal_information_for_vulnerabilities_spec.rb delete mode 100644 spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb delete mode 100644 spec/migrations/schedule_populate_project_snippet_statistics_spec.rb delete mode 100644 spec/migrations/schedule_populate_user_highest_roles_table_spec.rb delete mode 100644 spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb delete mode 100644 spec/migrations/schedule_recalculate_project_authorizations_spec.rb delete mode 100644 spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb delete mode 100644 spec/migrations/schedule_repopulate_historical_vulnerability_statistics_spec.rb delete mode 100644 spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb delete mode 100644 spec/migrations/schedule_update_existing_users_that_require_two_factor_auth_spec.rb delete mode 100644 spec/migrations/seed_merge_trains_enabled_spec.rb delete mode 100644 spec/migrations/seed_repository_storages_weighted_spec.rb delete mode 100644 spec/migrations/services_remove_temporary_index_on_project_id_spec.rb delete mode 100644 spec/migrations/set_job_waiter_ttl_spec.rb delete mode 100644 spec/migrations/unconfirm_wrongfully_verified_emails_spec.rb delete mode 100644 spec/migrations/update_application_setting_npm_package_requests_forwarding_default_spec.rb delete mode 100644 spec/migrations/update_fingerprint_sha256_within_keys_spec.rb delete mode 100644 spec/migrations/update_historical_data_recorded_at_spec.rb delete mode 100644 spec/migrations/update_internal_ids_last_value_for_epics_renamed_spec.rb delete mode 100644 spec/migrations/update_routes_for_lost_and_found_group_and_orphaned_projects_spec.rb delete mode 100644 spec/migrations/update_timestamp_softwarelicensespolicy_spec.rb create mode 100644 spec/models/acts_as_taggable_on/tag_spec.rb create mode 100644 spec/models/acts_as_taggable_on/tagging_spec.rb create mode 100644 spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb create mode 100644 spec/models/concerns/database_reflection_spec.rb delete mode 100644 spec/models/concerns/has_integrations_spec.rb create mode 100644 spec/models/concerns/legacy_bulk_insert_spec.rb create mode 100644 spec/models/customer_relations/issue_contact_spec.rb create mode 100644 spec/models/data_list_spec.rb create mode 100644 spec/models/integrations/shimo_spec.rb create mode 100644 spec/models/loose_foreign_keys/deleted_record_spec.rb create mode 100644 spec/models/loose_foreign_keys/modification_tracker_spec.rb create mode 100644 spec/models/members/member_task_spec.rb create mode 100644 spec/models/packages/npm/metadatum_spec.rb create mode 100644 spec/models/preloaders/group_policy_preloader_spec.rb create mode 100644 spec/models/preloaders/group_root_ancestor_preloader_spec.rb create mode 100644 spec/models/webauthn_registration_spec.rb create mode 100644 spec/requests/admin/applications_controller_spec.rb create mode 100644 spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb create mode 100644 spec/requests/api/graphql/mutations/merge_requests/set_draft_spec.rb delete mode 100644 spec/requests/api/graphql/mutations/merge_requests/set_wip_spec.rb create mode 100644 spec/requests/api/graphql/mutations/merge_requests/update_reviewer_state_spec.rb create mode 100644 spec/requests/api/graphql/mutations/security/ci_configuration/configure_sast_iac_spec.rb create mode 100644 spec/requests/api/graphql/namespace_query_spec.rb create mode 100644 spec/requests/api/graphql/packages/helm_spec.rb create mode 100644 spec/requests/api/topics_spec.rb create mode 100644 spec/requests/groups/settings/applications_controller_spec.rb create mode 100644 spec/requests/oauth/applications_controller_spec.rb create mode 100644 spec/requests/projects/issues/discussions_spec.rb create mode 100644 spec/requests/projects/issues_controller_spec.rb create mode 100644 spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb create mode 100644 spec/scripts/changed-feature-flags_spec.rb create mode 100644 spec/scripts/failed_tests_spec.rb create mode 100644 spec/scripts/pipeline_test_report_builder_spec.rb create mode 100644 spec/services/authorized_project_update/project_access_changed_service_spec.rb create mode 100644 spec/services/award_emojis/base_service_spec.rb create mode 100644 spec/services/ci/generate_kubeconfig_service_spec.rb delete mode 100644 spec/services/clusters/applications/prometheus_health_check_service_spec.rb create mode 100644 spec/services/clusters/integrations/prometheus_health_check_service_spec.rb create mode 100644 spec/services/deployments/archive_in_project_service_spec.rb create mode 100644 spec/services/google_cloud/service_accounts_service_spec.rb create mode 100644 spec/services/import/github/notes/create_service_spec.rb create mode 100644 spec/services/issues/set_crm_contacts_service_spec.rb create mode 100644 spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb create mode 100644 spec/services/loose_foreign_keys/cleaner_service_spec.rb create mode 100644 spec/services/merge_requests/toggle_attention_requested_service_spec.rb create mode 100644 spec/services/namespaces/in_product_marketing_email_records_spec.rb create mode 100644 spec/services/namespaces/invite_team_email_service_spec.rb create mode 100644 spec/services/projects/all_issues_count_service_spec.rb create mode 100644 spec/services/projects/all_merge_requests_count_service_spec.rb delete mode 100644 spec/services/projects/container_repository/cache_tags_created_at_service_spec.rb create mode 100644 spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb create mode 100644 spec/services/security/ci_configuration/sast_iac_create_service_spec.rb create mode 100644 spec/services/tasks_to_be_done/base_service_spec.rb create mode 100644 spec/sidekiq_cluster/sidekiq_cluster_spec.rb delete mode 100644 spec/support/database/gitlab_schema.rb create mode 100644 spec/support/database/query_analyzer.rb create mode 100644 spec/support/flaky_tests.rb create mode 100644 spec/support/graphql/fake_query_type.rb create mode 100644 spec/support/graphql/fake_tracer.rb create mode 100644 spec/support/matchers/project_namespace_matcher.rb create mode 100644 spec/support/patches/rspec_example_prepended_methods.rb create mode 100644 spec/support/retriable.rb delete mode 100644 spec/support/shared_contexts/lib/gitlab/database/background_migration_job_shared_context.rb create mode 100644 spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb create mode 100644 spec/support/shared_examples/bulk_imports/common/pipelines/wiki_pipeline_examples.rb create mode 100644 spec/support/shared_examples/controllers/concerns/integrations/integrations_actions_shared_examples.rb delete mode 100644 spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb create mode 100644 spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb create mode 100644 spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb create mode 100644 spec/support/shared_examples/metrics/transaction_metrics_with_labels_shared_examples.rb create mode 100644 spec/support/shared_examples/models/reviewer_state_shared_examples.rb create mode 100644 spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb create mode 100644 spec/support/shared_examples/requests/api/debian_common_shared_examples.rb create mode 100644 spec/support/shared_examples/requests/api/debian_distributions_shared_examples.rb create mode 100644 spec/support/shared_examples/requests/applications_controller_shared_examples.rb create mode 100644 spec/support/shared_examples/services/resource_events/synthetic_notes_builder_shared_examples.rb create mode 100644 spec/support/time_travel.rb delete mode 100644 spec/support_specs/database/prevent_cross_database_modification_spec.rb create mode 100644 spec/support_specs/time_travel_spec.rb create mode 100644 spec/views/layouts/_published_experiments.html.haml_spec.rb create mode 100644 spec/views/profiles/audit_log.html.haml_spec.rb create mode 100644 spec/views/projects/issues/_service_desk_info_content.html.haml_spec.rb delete mode 100644 spec/workers/clusters/applications/check_prometheus_health_worker_spec.rb create mode 100644 spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb create mode 100644 spec/workers/deployments/archive_in_project_worker_spec.rb create mode 100644 spec/workers/integrations/create_external_cross_reference_worker_spec.rb create mode 100644 spec/workers/issues/placement_worker_spec.rb create mode 100644 spec/workers/issues/rebalancing_worker_spec.rb create mode 100644 spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb create mode 100644 spec/workers/loose_foreign_keys/cleanup_worker_spec.rb create mode 100644 spec/workers/namespaces/invite_team_email_worker_spec.rb create mode 100644 spec/workers/tasks_to_be_done/create_worker_spec.rb (limited to 'spec') diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb new file mode 100644 index 00000000000..baa4a2b4ec3 --- /dev/null +++ b/spec/commands/sidekiq_cluster/cli_spec.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' + +require_relative '../../../sidekiq_cluster/cli' + +RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath + let(:cli) { described_class.new('/dev/null') } + let(:timeout) { Gitlab::SidekiqCluster::DEFAULT_SOFT_TIMEOUT_SECONDS } + let(:default_options) do + { env: 'test', directory: Dir.pwd, max_concurrency: 50, min_concurrency: 0, dryrun: false, timeout: timeout } + end + + before do + stub_env('RAILS_ENV', 'test') + end + + describe '#run' do + context 'without any arguments' do + it 'raises CommandError' do + expect { cli.run([]) }.to raise_error(described_class::CommandError) + end + end + + context 'with arguments' do + before do + allow(cli).to receive(:write_pid) + allow(cli).to receive(:trap_signals) + allow(cli).to receive(:start_loop) + end + + it 'starts the Sidekiq workers' do + expect(Gitlab::SidekiqCluster).to receive(:start) + .with([['foo']], default_options) + .and_return([]) + + cli.run(%w(foo)) + end + + it 'allows the special * selector' do + worker_queues = %w(foo bar baz) + + expect(Gitlab::SidekiqConfig::CliMethods) + .to receive(:worker_queues).and_return(worker_queues) + + expect(Gitlab::SidekiqCluster) + .to receive(:start).with([worker_queues], default_options) + + cli.run(%w(*)) + end + + it 'raises an error when the arguments contain newlines' do + invalid_arguments = [ + ["foo\n"], + ["foo\r"], + %W[foo b\nar] + ] + + invalid_arguments.each do |arguments| + expect { cli.run(arguments) }.to raise_error(described_class::CommandError) + end + end + + context 'with --negate flag' do + it 'starts Sidekiq workers for all queues in all_queues.yml except the ones in argv' do + expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(['baz']) + expect(Gitlab::SidekiqCluster).to receive(:start) + .with([['baz']], default_options) + .and_return([]) + + cli.run(%w(foo -n)) + end + end + + context 'with --max-concurrency flag' do + it 'starts Sidekiq workers for specified queues with a max concurrency' do + expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(%w(foo bar baz)) + expect(Gitlab::SidekiqCluster).to receive(:start) + .with([%w(foo bar baz), %w(solo)], default_options.merge(max_concurrency: 2)) + .and_return([]) + + cli.run(%w(foo,bar,baz solo -m 2)) + end + end + + context 'with --min-concurrency flag' do + it 'starts Sidekiq workers for specified queues with a min concurrency' do + expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(%w(foo bar baz)) + expect(Gitlab::SidekiqCluster).to receive(:start) + .with([%w(foo bar baz), %w(solo)], default_options.merge(min_concurrency: 2)) + .and_return([]) + + cli.run(%w(foo,bar,baz solo --min-concurrency 2)) + end + end + + context 'with --timeout flag' do + it 'when given', 'starts Sidekiq workers with given timeout' do + expect(Gitlab::SidekiqCluster).to receive(:start) + .with([['foo']], default_options.merge(timeout: 10)) + + cli.run(%w(foo --timeout 10)) + end + + it 'when not given', 'starts Sidekiq workers with default timeout' do + expect(Gitlab::SidekiqCluster).to receive(:start) + .with([['foo']], default_options.merge(timeout: Gitlab::SidekiqCluster::DEFAULT_SOFT_TIMEOUT_SECONDS)) + + cli.run(%w(foo)) + end + end + + context 'with --list-queues flag' do + it 'errors when given --list-queues and --dryrun' do + expect { cli.run(%w(foo --list-queues --dryrun)) }.to raise_error(described_class::CommandError) + end + + it 'prints out a list of queues in alphabetical order' do + expected_queues = [ + 'epics:epics_update_epics_dates', + 'epics_new_epic_issue', + 'new_epic', + 'todos_destroyer:todos_destroyer_confidential_epic' + ] + + allow(Gitlab::SidekiqConfig::CliMethods).to receive(:query_queues).and_return(expected_queues.shuffle) + + expect(cli).to receive(:puts).with([expected_queues]) + + cli.run(%w(--queue-selector feature_category=epics --list-queues)) + end + end + + context 'queue namespace expansion' do + it 'starts Sidekiq workers for all queues in all_queues.yml with a namespace in argv' do + expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(['cronjob:foo', 'cronjob:bar']) + expect(Gitlab::SidekiqCluster).to receive(:start) + .with([['cronjob', 'cronjob:foo', 'cronjob:bar']], default_options) + .and_return([]) + + cli.run(%w(cronjob)) + end + end + + context "with --queue-selector" do + where do + { + 'memory-bound queues' => { + query: 'resource_boundary=memory', + included_queues: %w(project_export), + excluded_queues: %w(merge) + }, + 'memory- or CPU-bound queues' => { + query: 'resource_boundary=memory,cpu', + included_queues: %w(auto_merge:auto_merge_process project_export), + excluded_queues: %w(merge) + }, + 'high urgency CI queues' => { + query: 'feature_category=continuous_integration&urgency=high', + included_queues: %w(pipeline_cache:expire_job_cache pipeline_cache:expire_pipeline_cache), + excluded_queues: %w(merge) + }, + 'CPU-bound high urgency CI queues' => { + query: 'feature_category=continuous_integration&urgency=high&resource_boundary=cpu', + included_queues: %w(pipeline_cache:expire_pipeline_cache), + excluded_queues: %w(pipeline_cache:expire_job_cache merge) + }, + 'CPU-bound high urgency non-CI queues' => { + query: 'feature_category!=continuous_integration&urgency=high&resource_boundary=cpu', + included_queues: %w(new_issue), + excluded_queues: %w(pipeline_cache:expire_pipeline_cache) + }, + 'CI and SCM queues' => { + query: 'feature_category=continuous_integration|feature_category=source_code_management', + included_queues: %w(pipeline_cache:expire_job_cache merge), + excluded_queues: %w(mailers) + } + } + end + + with_them do + it 'expands queues by attributes' do + expect(Gitlab::SidekiqCluster).to receive(:start) do |queues, opts| + expect(opts).to eq(default_options) + expect(queues.first).to include(*included_queues) + expect(queues.first).not_to include(*excluded_queues) + + [] + end + + cli.run(%W(--queue-selector #{query})) + end + + it 'works when negated' do + expect(Gitlab::SidekiqCluster).to receive(:start) do |queues, opts| + expect(opts).to eq(default_options) + expect(queues.first).not_to include(*included_queues) + expect(queues.first).to include(*excluded_queues) + + [] + end + + cli.run(%W(--negate --queue-selector #{query})) + end + end + + it 'expands multiple queue groups correctly' do + expect(Gitlab::SidekiqCluster) + .to receive(:start) + .with([['chat_notification'], ['project_export']], default_options) + .and_return([]) + + cli.run(%w(--queue-selector feature_category=chatops&has_external_dependencies=true resource_boundary=memory&feature_category=importers)) + end + + it 'allows the special * selector' do + worker_queues = %w(foo bar baz) + + expect(Gitlab::SidekiqConfig::CliMethods) + .to receive(:worker_queues).and_return(worker_queues) + + expect(Gitlab::SidekiqCluster) + .to receive(:start).with([worker_queues], default_options) + + cli.run(%w(--queue-selector *)) + end + + it 'errors when the selector matches no queues' do + expect(Gitlab::SidekiqCluster).not_to receive(:start) + + expect { cli.run(%w(--queue-selector has_external_dependencies=true&has_external_dependencies=false)) } + .to raise_error(described_class::CommandError) + end + + it 'errors on an invalid query multiple queue groups correctly' do + expect(Gitlab::SidekiqCluster).not_to receive(:start) + + expect { cli.run(%w(--queue-selector unknown_field=chatops)) } + .to raise_error(Gitlab::SidekiqConfig::WorkerMatcher::QueryError) + end + end + end + end + + describe '#write_pid' do + context 'when a PID is specified' do + it 'writes the PID to a file' do + expect(Gitlab::SidekiqCluster).to receive(:write_pid).with('/dev/null') + + cli.option_parser.parse!(%w(-P /dev/null)) + cli.write_pid + end + end + + context 'when no PID is specified' do + it 'does not write a PID' do + expect(Gitlab::SidekiqCluster).not_to receive(:write_pid) + + cli.write_pid + end + end + end + + describe '#wait_for_termination' do + it 'waits for termination of all sub-processes and succeeds after 3 checks' do + expect(Gitlab::SidekiqCluster).to receive(:any_alive?) + .with(an_instance_of(Array)).and_return(true, true, true, false) + + expect(Gitlab::SidekiqCluster).to receive(:pids_alive) + .with([]).and_return([]) + + expect(Gitlab::SidekiqCluster).to receive(:signal_processes) + .with([], "-KILL") + + stub_const("Gitlab::SidekiqCluster::CHECK_TERMINATE_INTERVAL_SECONDS", 0.1) + allow(cli).to receive(:terminate_timeout_seconds) { 1 } + + cli.wait_for_termination + end + + context 'with hanging workers' do + before do + expect(cli).to receive(:write_pid) + expect(cli).to receive(:trap_signals) + expect(cli).to receive(:start_loop) + end + + it 'hard kills workers after timeout expires' do + worker_pids = [101, 102, 103] + expect(Gitlab::SidekiqCluster).to receive(:start) + .with([['foo']], default_options) + .and_return(worker_pids) + + expect(Gitlab::SidekiqCluster).to receive(:any_alive?) + .with(worker_pids).and_return(true).at_least(10).times + + expect(Gitlab::SidekiqCluster).to receive(:pids_alive) + .with(worker_pids).and_return([102]) + + expect(Gitlab::SidekiqCluster).to receive(:signal_processes) + .with([102], "-KILL") + + cli.run(%w(foo)) + + stub_const("Gitlab::SidekiqCluster::CHECK_TERMINATE_INTERVAL_SECONDS", 0.1) + allow(cli).to receive(:terminate_timeout_seconds) { 1 } + + cli.wait_for_termination + end + end + end + + describe '#trap_signals' do + it 'traps the termination and forwarding signals' do + expect(Gitlab::SidekiqCluster).to receive(:trap_terminate) + expect(Gitlab::SidekiqCluster).to receive(:trap_forward) + + cli.trap_signals + end + end + + describe '#start_loop' do + it 'runs until one of the processes has been terminated' do + allow(cli).to receive(:sleep).with(a_kind_of(Numeric)) + + expect(Gitlab::SidekiqCluster).to receive(:all_alive?) + .with(an_instance_of(Array)).and_return(false) + + expect(Gitlab::SidekiqCluster).to receive(:signal_processes) + .with(an_instance_of(Array), :TERM) + + cli.start_loop + end + end +end diff --git a/spec/controllers/admin/integrations_controller_spec.rb b/spec/controllers/admin/integrations_controller_spec.rb index 1793b3a86d1..cf6a6385425 100644 --- a/spec/controllers/admin/integrations_controller_spec.rb +++ b/spec/controllers/admin/integrations_controller_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Admin::IntegrationsController do sign_in(admin) end - it_behaves_like IntegrationsActions do + it_behaves_like Integrations::Actions do let(:integration_attributes) { { instance: true, project: nil } } let(:routing_params) do diff --git a/spec/controllers/admin/runners_controller_spec.rb b/spec/controllers/admin/runners_controller_spec.rb index 996964fdcf0..b9a59e9ae5f 100644 --- a/spec/controllers/admin/runners_controller_spec.rb +++ b/spec/controllers/admin/runners_controller_spec.rb @@ -12,9 +12,11 @@ RSpec.describe Admin::RunnersController do describe '#index' do render_views - it 'lists all runners' do + before do get :index + end + it 'renders index template' do expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template(:index) end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index e9a49319f21..e623c1ab940 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -501,11 +501,16 @@ RSpec.describe ApplicationController do describe '#append_info_to_payload' do controller(described_class) do attr_reader :last_payload + urgency :high, [:foo] def index render html: 'authenticated' end + def foo + render html: '' + end + def append_info_to_payload(payload) super @@ -513,6 +518,13 @@ RSpec.describe ApplicationController do end end + before do + routes.draw do + get 'index' => 'anonymous#index' + get 'foo' => 'anonymous#foo' + end + end + it 'does not log errors with a 200 response' do get :index @@ -534,6 +546,22 @@ RSpec.describe ApplicationController do expect(controller.last_payload[:metadata]).to include('meta.user' => user.username) end + + context 'urgency information' do + it 'adds default urgency information to the payload' do + get :index + + expect(controller.last_payload[:request_urgency]).to eq(:default) + expect(controller.last_payload[:target_duration_s]).to eq(1) + end + + it 'adds customized urgency information to the payload' do + get :foo + + expect(controller.last_payload[:request_urgency]).to eq(:high) + expect(controller.last_payload[:target_duration_s]).to eq(0.25) + end + end end describe '#access_denied' do @@ -895,7 +923,7 @@ RSpec.describe ApplicationController do describe '#set_current_context' do controller(described_class) do - feature_category :issue_tracking + feature_category :team_planning def index Gitlab::ApplicationContext.with_raw_context do |context| @@ -949,7 +977,7 @@ RSpec.describe ApplicationController do it 'sets the feature_category as defined in the controller' do get :index, format: :json - expect(json_response['meta.feature_category']).to eq('issue_tracking') + expect(json_response['meta.feature_category']).to eq('team_planning') end it 'assigns the context to a variable for logging' do diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb index e808f1caa6e..921706b2042 100644 --- a/spec/controllers/concerns/group_tree_spec.rb +++ b/spec/controllers/concerns/group_tree_spec.rb @@ -102,13 +102,5 @@ RSpec.describe GroupTree do end it_behaves_like 'returns filtered groups' - - context 'when feature flag :linear_group_tree_ancestor_scopes is disabled' do - before do - stub_feature_flags(linear_group_tree_ancestor_scopes: false) - end - - it_behaves_like 'returns filtered groups' - end end end diff --git a/spec/controllers/concerns/import_url_params_spec.rb b/spec/controllers/concerns/import_url_params_spec.rb index 72f13cdcc94..ddffb243f7a 100644 --- a/spec/controllers/concerns/import_url_params_spec.rb +++ b/spec/controllers/concerns/import_url_params_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe ImportUrlParams do let(:import_url_params) do - controller = OpenStruct.new(params: params).extend(described_class) + controller = double('controller', params: params).extend(described_class) controller.import_url_params end diff --git a/spec/controllers/concerns/renders_commits_spec.rb b/spec/controllers/concerns/renders_commits_spec.rb index 5c918267f50..acdeb98bb16 100644 --- a/spec/controllers/concerns/renders_commits_spec.rb +++ b/spec/controllers/concerns/renders_commits_spec.rb @@ -64,6 +64,12 @@ RSpec.describe RendersCommits do subject.prepare_commits_for_rendering(merge_request.commits.take(1)) end + # Populate Banzai::Filter::References::ReferenceCache + subject.prepare_commits_for_rendering(merge_request.commits) + + # Reset lazy_latest_pipeline cache to simulate a new request + BatchLoader::Executor.clear_current + expect do subject.prepare_commits_for_rendering(merge_request.commits) merge_request.commits.each(&:latest_pipeline) diff --git a/spec/controllers/confirmations_controller_spec.rb b/spec/controllers/confirmations_controller_spec.rb index 401ee36b387..1c7f8de32bb 100644 --- a/spec/controllers/confirmations_controller_spec.rb +++ b/spec/controllers/confirmations_controller_spec.rb @@ -123,4 +123,45 @@ RSpec.describe ConfirmationsController do end end end + + describe '#create' do + let(:user) { create(:user) } + + subject(:perform_request) { post(:create, params: { user: { email: user.email } }) } + + context 'when reCAPTCHA is disabled' do + before do + stub_application_setting(recaptcha_enabled: false) + end + + it 'successfully sends password reset when reCAPTCHA is not solved' do + perform_request + + expect(response).to redirect_to(dashboard_projects_path) + end + end + + context 'when reCAPTCHA is enabled' do + before do + stub_application_setting(recaptcha_enabled: true) + end + + it 'displays an error when the reCAPTCHA is not solved' do + Recaptcha.configuration.skip_verify_env.delete('test') + + perform_request + + expect(response).to render_template(:new) + expect(flash[:alert]).to include 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' + end + + it 'successfully sends password reset when reCAPTCHA is solved' do + Recaptcha.configuration.skip_verify_env << 'test' + + perform_request + + expect(response).to redirect_to(dashboard_projects_path) + end + end + end end diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb index f0aa351bee0..cf528b414c0 100644 --- a/spec/controllers/dashboard/todos_controller_spec.rb +++ b/spec/controllers/dashboard/todos_controller_spec.rb @@ -62,7 +62,7 @@ RSpec.describe Dashboard::TodosController do create(:issue, project: project, assignees: [user]) group_2 = create(:group) group_2.add_owner(user) - project_2 = create(:project) + project_2 = create(:project, namespace: user.namespace) project_2.add_developer(user) merge_request_2 = create(:merge_request, source_project: project_2) create(:todo, project: project, author: author, user: user, target: merge_request_2) diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index 2297198878d..f2328303102 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -74,6 +74,28 @@ RSpec.describe Explore::ProjectsController do end end end + + describe 'GET #topic' do + context 'when topic does not exist' do + it 'renders a 404 error' do + get :topic, params: { topic_name: 'topic1' } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'when topic exists' do + before do + create(:topic, name: 'topic1') + end + + it 'renders the template' 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 shared_examples "blocks high page numbers" do diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb index fa402d556c7..b22307578ab 100644 --- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb @@ -124,6 +124,34 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end + shared_examples 'authorize action with permission' do + context 'with a valid user' do + before do + group.add_guest(user) + end + + it 'sends Workhorse local file instructions', :aggregate_failures do + subject + + expect(response.headers['Content-Type']).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).to eq(DependencyProxy::FileUploader.workhorse_local_upload_path) + expect(json_response['RemoteObject']).to be_nil + expect(json_response['MaximumSize']).to eq(maximum_size) + end + + it 'sends Workhorse remote object instructions', :aggregate_failures do + stub_dependency_proxy_object_storage(direct_upload: true) + + subject + + expect(response.headers['Content-Type']).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).to be_nil + expect(json_response['RemoteObject']).not_to be_nil + expect(json_response['MaximumSize']).to eq(maximum_size) + end + end + end + before do allow(Gitlab.config.dependency_proxy) .to receive(:enabled).and_return(true) @@ -136,7 +164,8 @@ RSpec.describe Groups::DependencyProxyForContainersController do end describe 'GET #manifest' do - let_it_be(:manifest) { create(:dependency_proxy_manifest) } + let_it_be(:tag) { 'latest' } + let_it_be(:manifest) { create(:dependency_proxy_manifest, file_name: "alpine:#{tag}.json", group: group) } let(:pull_response) { { status: :success, manifest: manifest, from_cache: false } } @@ -146,7 +175,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end - subject { get_manifest } + subject { get_manifest(tag) } context 'feature enabled' do before do @@ -207,11 +236,26 @@ RSpec.describe Groups::DependencyProxyForContainersController do it_behaves_like 'a successful manifest pull' it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest' - context 'with a cache entry' do - let(:pull_response) { { status: :success, manifest: manifest, from_cache: true } } + context 'with workhorse response' do + let(:pull_response) { { status: :success, manifest: nil, from_cache: false } } - it_behaves_like 'returning response status', :success - it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest_from_cache' + it 'returns Workhorse send-dependency instructions', :aggregate_failures do + subject + + send_data_type, send_data = workhorse_send_data + header, url = send_data.values_at('Header', 'Url') + + expect(send_data_type).to eq('send-dependency') + expect(header).to eq( + "Authorization" => ["Bearer abcd1234"], + "Accept" => ::ContainerRegistry::Client::ACCEPTED_TYPES + ) + expect(url).to eq(DependencyProxy::Registry.manifest_url('alpine', tag)) + expect(response.headers['Content-Type']).to eq('application/gzip') + expect(response.headers['Content-Disposition']).to eq( + ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: manifest.file_name) + ) + end end end @@ -237,8 +281,8 @@ RSpec.describe Groups::DependencyProxyForContainersController do it_behaves_like 'not found when disabled' - def get_manifest - get :manifest, params: { group_id: group.to_param, image: 'alpine', tag: '3.9.2' } + def get_manifest(tag) + get :manifest, params: { group_id: group.to_param, image: 'alpine', tag: tag } end end @@ -381,39 +425,28 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end - describe 'GET #authorize_upload_blob' do + describe 'POST #authorize_upload_blob' do let(:blob_sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' } + let(:maximum_size) { DependencyProxy::Blob::MAX_FILE_SIZE } - subject(:authorize_upload_blob) do + subject do request.headers.merge!(workhorse_internal_api_request_header) - get :authorize_upload_blob, params: { group_id: group.to_param, image: 'alpine', sha: blob_sha } + post :authorize_upload_blob, params: { group_id: group.to_param, image: 'alpine', sha: blob_sha } end it_behaves_like 'without permission' - - context 'with a valid user' do - before do - group.add_guest(user) - end - - it 'sends Workhorse file upload instructions', :aggregate_failures do - authorize_upload_blob - - expect(response.headers['Content-Type']).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) - expect(json_response['TempPath']).to eq(DependencyProxy::FileUploader.workhorse_local_upload_path) - end - end + it_behaves_like 'authorize action with permission' end - describe 'GET #upload_blob' do + describe 'POST #upload_blob' do let(:blob_sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' } let(:file) { fixture_file_upload("spec/fixtures/dependency_proxy/#{blob_sha}.gz", 'application/gzip') } subject do request.headers.merge!(workhorse_internal_api_request_header) - get :upload_blob, params: { + post :upload_blob, params: { group_id: group.to_param, image: 'alpine', sha: blob_sha, @@ -436,6 +469,79 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end + describe 'POST #authorize_upload_manifest' do + let(:maximum_size) { DependencyProxy::Manifest::MAX_FILE_SIZE } + + subject do + request.headers.merge!(workhorse_internal_api_request_header) + + post :authorize_upload_manifest, params: { group_id: group.to_param, image: 'alpine', tag: 'latest' } + end + + it_behaves_like 'without permission' + it_behaves_like 'authorize action with permission' + end + + describe 'POST #upload_manifest' do + let_it_be(:file) { fixture_file_upload("spec/fixtures/dependency_proxy/manifest", 'application/json') } + let_it_be(:image) { 'alpine' } + let_it_be(:tag) { 'latest' } + let_it_be(:content_type) { 'v2/manifest' } + let_it_be(:digest) { 'foo' } + let_it_be(:file_name) { "#{image}:#{tag}.json" } + + subject do + request.headers.merge!( + workhorse_internal_api_request_header.merge!( + { + Gitlab::Workhorse::SEND_DEPENDENCY_CONTENT_TYPE_HEADER => content_type, + DependencyProxy::Manifest::DIGEST_HEADER => digest + } + ) + ) + params = { + group_id: group.to_param, + image: image, + tag: tag, + file: file, + file_name: file_name + } + + post :upload_manifest, params: params + end + + it_behaves_like 'without permission' + + context 'with a valid user' do + before do + group.add_guest(user) + end + + it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest' + + context 'with no existing manifest' do + it 'creates a manifest' do + expect { subject }.to change { group.dependency_proxy_manifests.count }.by(1) + + manifest = group.dependency_proxy_manifests.first.reload + expect(manifest.content_type).to eq(content_type) + expect(manifest.digest).to eq(digest) + expect(manifest.file_name).to eq(file_name) + end + end + + context 'with existing stale manifest' do + let_it_be(:old_digest) { 'asdf' } + let_it_be_with_reload(:manifest) { create(:dependency_proxy_manifest, file_name: file_name, digest: old_digest, group: group) } + + it 'updates the existing manifest' do + expect { subject }.to change { group.dependency_proxy_manifests.count }.by(0) + .and change { manifest.reload.digest }.from(old_digest).to(digest) + end + end + end + end + def enable_dependency_proxy group.create_dependency_proxy_setting!(enabled: true) end diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb index 31d1946652d..c070094babd 100644 --- a/spec/controllers/groups/settings/integrations_controller_spec.rb +++ b/spec/controllers/groups/settings/integrations_controller_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Groups::Settings::IntegrationsController do sign_in(user) end - it_behaves_like IntegrationsActions do + it_behaves_like Integrations::Actions do let(:integration_attributes) { { group: group, project: nil } } let(:routing_params) do @@ -78,7 +78,7 @@ RSpec.describe Groups::Settings::IntegrationsController do describe '#update' do include JiraServiceHelper - let(:integration) { create(:jira_integration, project: nil, group_id: group.id) } + let(:integration) { create(:jira_integration, :group, group: group) } before do group.add_owner(user) @@ -108,7 +108,7 @@ RSpec.describe Groups::Settings::IntegrationsController do end describe '#reset' do - let_it_be(:integration) { create(:jira_integration, group: group, project: nil) } + let_it_be(:integration) { create(:jira_integration, :group, group: group) } let_it_be(:inheriting_integration) { create(:jira_integration, inherit_from_id: integration.id) } subject do diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index a7625e65603..2525146c673 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -82,6 +82,16 @@ RSpec.describe GroupsController, factory_default: :keep do expect(subject).to redirect_to group_import_path(group) end end + + context 'publishing the invite_members_for_task experiment' do + it 'publishes the experiment data to the client' do + wrapped_experiment(experiment(:invite_members_for_task)) do |e| + expect(e).to receive(:publish_to_client) + end + + get :show, params: { id: group.to_param, open_modal: 'invite_members_for_task' }, format: format + end + end end describe 'GET #details' do diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 0427715d1ac..91e43adc472 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -252,6 +252,30 @@ RSpec.describe Import::BitbucketController do end end end + + context "when exceptions occur" do + shared_examples "handles exceptions" do + it "logs an exception" do + expect(Bitbucket::Client).to receive(:new).and_raise(error) + expect(controller).to receive(:log_exception) + + post :create, format: :json + end + end + + context "for OAuth2 errors" do + let(:fake_response) { double('Faraday::Response', headers: {}, body: '', status: 403) } + let(:error) { OAuth2::Error.new(OAuth2::Response.new(fake_response)) } + + it_behaves_like "handles exceptions" + end + + context "for Bitbucket errors" do + let(:error) { Bitbucket::Error::Unauthorized.new("error") } + + it_behaves_like "handles exceptions" + end + end end context 'user has chosen an existing nested namespace and name for the project' do diff --git a/spec/controllers/jira_connect/app_descriptor_controller_spec.rb b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb index 9d890efdd33..4f8b2b90637 100644 --- a/spec/controllers/jira_connect/app_descriptor_controller_spec.rb +++ b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb @@ -90,17 +90,5 @@ RSpec.describe JiraConnect::AppDescriptorController do ) ) end - - context 'when jira_connect_asymmetric_jwt is disabled' do - before do - stub_feature_flags(jira_connect_asymmetric_jwt: false) - end - - specify do - get :show - - expect(json_response).to include('apiMigrations' => include('signed-install' => false)) - end - end end end diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb index 78bd0dc8318..2a70a2ea683 100644 --- a/spec/controllers/jira_connect/events_controller_spec.rb +++ b/spec/controllers/jira_connect/events_controller_spec.rb @@ -77,18 +77,6 @@ RSpec.describe JiraConnect::EventsController do expect(installation.base_url).to eq('https://test.atlassian.net') end - context 'when jira_connect_asymmetric_jwt is disabled' do - before do - stub_feature_flags(jira_connect_asymmetric_jwt: false) - end - - it 'saves the jira installation data without JWT validation' do - expect(Atlassian::JiraConnect::AsymmetricJwt).not_to receive(:new) - - expect { subject }.to change { JiraConnectInstallation.count }.by(1) - end - end - context 'when it is a version update and shared_secret is not sent' do let(:params) do { @@ -110,22 +98,6 @@ RSpec.describe JiraConnect::EventsController do expect { subject }.not_to change { JiraConnectInstallation.count } expect(response).to have_gitlab_http_status(:ok) end - - context 'when jira_connect_asymmetric_jwt is disabled' do - before do - stub_feature_flags(jira_connect_asymmetric_jwt: false) - end - - it 'decodes the JWT token in authorization header and returns 200 without creating a new installation' do - request.headers["Authorization"] = "Bearer #{Atlassian::Jwt.encode({ iss: client_key }, shared_secret)}" - - expect(Atlassian::JiraConnect::AsymmetricJwt).not_to receive(:new) - - expect { subject }.not_to change { JiraConnectInstallation.count } - - expect(response).to have_gitlab_http_status(:ok) - end - end end end end @@ -153,23 +125,6 @@ RSpec.describe JiraConnect::EventsController do it 'does not delete the installation' do expect { post_uninstalled }.not_to change { JiraConnectInstallation.count } end - - context 'when jira_connect_asymmetric_jwt is disabled' do - before do - stub_feature_flags(jira_connect_asymmetric_jwt: false) - request.headers['Authorization'] = 'JWT invalid token' - end - - it 'returns 403' do - post_uninstalled - - expect(response).to have_gitlab_http_status(:forbidden) - end - - it 'does not delete the installation' do - expect { post_uninstalled }.not_to change { JiraConnectInstallation.count } - end - end end context 'when JWT is valid' do @@ -197,36 +152,6 @@ RSpec.describe JiraConnect::EventsController do expect(response).to have_gitlab_http_status(:unprocessable_entity) end - - context 'when jira_connect_asymmetric_jwt is disabled' do - before do - stub_feature_flags(jira_connect_asymmetric_jwt: false) - - request.headers['Authorization'] = "JWT #{Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret)}" - end - - let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/events/uninstalled', 'POST', 'https://gitlab.test') } - - it 'calls the DestroyService and returns ok in case of success' do - expect_next_instance_of(JiraConnectInstallations::DestroyService, installation, jira_base_path, jira_event_path) do |destroy_service| - expect(destroy_service).to receive(:execute).and_return(true) - end - - post_uninstalled - - expect(response).to have_gitlab_http_status(:ok) - end - - it 'calls the DestroyService and returns unprocessable_entity in case of failure' do - expect_next_instance_of(JiraConnectInstallations::DestroyService, installation, jira_base_path, jira_event_path) do |destroy_service| - expect(destroy_service).to receive(:execute).and_return(false) - end - - post_uninstalled - - expect(response).to have_gitlab_http_status(:unprocessable_entity) - end - end end end end diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb index 0e25f6a96d7..98cc8d83e0c 100644 --- a/spec/controllers/oauth/authorizations_controller_spec.rb +++ b/spec/controllers/oauth/authorizations_controller_spec.rb @@ -3,8 +3,7 @@ require 'spec_helper' RSpec.describe Oauth::AuthorizationsController do - let(:user) { create(:user, confirmed_at: confirmed_at) } - let(:confirmed_at) { 1.hour.ago } + let(:user) { create(:user) } let!(:application) { create(:oauth_application, scopes: 'api read_user', redirect_uri: 'http://example.com') } let(:params) do { @@ -40,7 +39,7 @@ RSpec.describe Oauth::AuthorizationsController do end context 'when the user is unconfirmed' do - let(:confirmed_at) { nil } + let(:user) { create(:user, :unconfirmed) } it 'returns 200 and renders error view' do subject @@ -73,8 +72,6 @@ RSpec.describe Oauth::AuthorizationsController do include_examples "Implicit grant can't be used in confidential application" context 'when the user is confirmed' do - let(:confirmed_at) { 1.hour.ago } - context 'when there is already an access token for the application with a matching scope' do before do scopes = Doorkeeper::OAuth::Scopes.from_string('api') diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb index 08d68d7cec8..01c032d9e3b 100644 --- a/spec/controllers/passwords_controller_spec.rb +++ b/spec/controllers/passwords_controller_spec.rb @@ -91,4 +91,47 @@ RSpec.describe PasswordsController do end end end + + describe '#create' do + let(:user) { create(:user) } + + subject(:perform_request) { post(:create, params: { user: { email: user.email } }) } + + context 'when reCAPTCHA is disabled' do + before do + stub_application_setting(recaptcha_enabled: false) + end + + it 'successfully sends password reset when reCAPTCHA is not solved' do + perform_request + + expect(response).to redirect_to(new_user_session_path) + expect(flash[:notice]).to include 'If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes.' + end + end + + context 'when reCAPTCHA is enabled' do + before do + stub_application_setting(recaptcha_enabled: true) + end + + it 'displays an error when the reCAPTCHA is not solved' do + Recaptcha.configuration.skip_verify_env.delete('test') + + perform_request + + expect(response).to render_template(:new) + expect(flash[:alert]).to include 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' + end + + it 'successfully sends password reset when reCAPTCHA is solved' do + Recaptcha.configuration.skip_verify_env << 'test' + + perform_request + + expect(response).to redirect_to(new_user_session_path) + expect(flash[:notice]).to include 'If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes.' + end + end + end end diff --git a/spec/controllers/profiles/accounts_controller_spec.rb b/spec/controllers/profiles/accounts_controller_spec.rb index c6e7866a659..011528016ce 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].each do |provider| + [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq, :dingtalk].each do |provider| describe "#{provider} provider" do let(:user) { create(:omniauth_user, provider: provider.to_s) } diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index e57bd5be937..47086ccdd2c 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -62,6 +62,32 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(flash[:alert]).to be_nil end end + + context 'when password authentication is disabled' do + before do + stub_application_setting(password_authentication_enabled_for_web: false) + end + + it 'does not require the current password', :aggregate_failures do + go + + expect(response).not_to redirect_to(redirect_path) + expect(flash[:alert]).to be_nil + end + end + + context 'when the user is an LDAP user' do + before do + allow(user).to receive(:ldap_user?).and_return(true) + end + + it 'does not require the current password', :aggregate_failures do + go + + expect(response).not_to redirect_to(redirect_path) + expect(flash[:alert]).to be_nil + end + end end describe 'GET show' do @@ -149,7 +175,7 @@ RSpec.describe Profiles::TwoFactorAuthsController do it 'assigns error' do go - expect(assigns[:error]).to eq _('Invalid pin code') + expect(assigns[:error]).to eq({ message: 'Invalid pin code.' }) end it 'assigns qr_code' do diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb index 4959003d788..9a1f8a8442d 100644 --- a/spec/controllers/profiles_controller_spec.rb +++ b/spec/controllers/profiles_controller_spec.rb @@ -125,6 +125,8 @@ RSpec.describe ProfilesController, :request_store do end describe 'GET audit_log' do + let(:auth_event) { create(:authentication_event, user: user) } + it 'tracks search event', :snowplow do sign_in(user) @@ -136,6 +138,14 @@ RSpec.describe ProfilesController, :request_store do user: user ) end + + it 'loads page correctly' do + sign_in(user) + + get :audit_log + + expect(response).to have_gitlab_http_status(:success) + end end describe 'PUT update_username' do diff --git a/spec/controllers/projects/alerting/notifications_controller_spec.rb b/spec/controllers/projects/alerting/notifications_controller_spec.rb index 2fff8026b22..b3feeb7c07b 100644 --- a/spec/controllers/projects/alerting/notifications_controller_spec.rb +++ b/spec/controllers/projects/alerting/notifications_controller_spec.rb @@ -16,7 +16,9 @@ RSpec.describe Projects::Alerting::NotificationsController do end shared_examples 'process alert payload' do |notify_service_class| - let(:service_response) { ServiceResponse.success } + let(:alert_1) { build(:alert_management_alert, project: project) } + let(:alert_2) { build(:alert_management_alert, project: project) } + let(:service_response) { ServiceResponse.success(payload: { alerts: [alert_1, alert_2] }) } let(:notify_service) { instance_double(notify_service_class, execute: service_response) } before do @@ -30,9 +32,13 @@ RSpec.describe Projects::Alerting::NotificationsController do context 'when notification service succeeds' do let(:permitted_params) { ActionController::Parameters.new(payload).permit! } - it 'responds with ok' do + it 'responds with the alert data' do make_request + expect(json_response).to contain_exactly( + { 'iid' => alert_1.iid, 'title' => alert_1.title }, + { 'iid' => alert_2.iid, 'title' => alert_2.title } + ) expect(response).to have_gitlab_http_status(:ok) end diff --git a/spec/controllers/projects/analytics/cycle_analytics/stages_controller_spec.rb b/spec/controllers/projects/analytics/cycle_analytics/stages_controller_spec.rb index 1351ba35a71..3f0318c3973 100644 --- a/spec/controllers/projects/analytics/cycle_analytics/stages_controller_spec.rb +++ b/spec/controllers/projects/analytics/cycle_analytics/stages_controller_spec.rb @@ -16,6 +16,7 @@ RSpec.describe Projects::Analytics::CycleAnalytics::StagesController do end before do + stub_feature_flags(use_vsa_aggregated_tables: false) sign_in(user) end diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 43e8bbd83cf..d9dedb04b0d 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -356,7 +356,7 @@ RSpec.describe Projects::BranchesController do context "valid branch name with encoded slashes" do let(:branch) { "improve%2Fawesome" } - it { expect(response).to have_gitlab_http_status(:ok) } + it { expect(response).to have_gitlab_http_status(:not_found) } it { expect(response.body).to be_blank } end @@ -396,10 +396,10 @@ RSpec.describe Projects::BranchesController do let(:branch) { 'improve%2Fawesome' } it 'returns JSON response with message' do - expect(json_response).to eql('message' => 'Branch was deleted') + expect(json_response).to eql('message' => 'No such branch') end - it { expect(response).to have_gitlab_http_status(:ok) } + it { expect(response).to have_gitlab_http_status(:not_found) } end context 'invalid branch name, valid ref' do diff --git a/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb index 942402a6d00..d55aad20689 100644 --- a/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb +++ b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb @@ -6,6 +6,8 @@ RSpec.describe Projects::Ci::PipelineEditorController do let_it_be(:project) { create(:project, :repository) } let_it_be(:user) { create(:user) } + subject(:show_request) { get :show, params: { namespace_id: project.namespace, project_id: project } } + before do sign_in(user) end @@ -14,8 +16,7 @@ RSpec.describe Projects::Ci::PipelineEditorController do context 'with enough privileges' do before do project.add_developer(user) - - get :show, params: { namespace_id: project.namespace, project_id: project } + show_request end it { expect(response).to have_gitlab_http_status(:ok) } @@ -28,13 +29,24 @@ RSpec.describe Projects::Ci::PipelineEditorController do context 'without enough privileges' do before do project.add_reporter(user) - - get :show, params: { namespace_id: project.namespace, project_id: project } + show_request end it 'responds with 404' do expect(response).to have_gitlab_http_status(:not_found) end end + + describe 'pipeline_editor_walkthrough experiment' do + before do + project.add_developer(user) + end + + subject(:action) { show_request } + + it_behaves_like 'tracks assignment and records the subject', :pipeline_editor_walkthrough, :namespace do + subject { project.namespace } + end + end end end diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb index 4cf77fde3a1..a8e71d73beb 100644 --- a/spec/controllers/projects/commits_controller_spec.rb +++ b/spec/controllers/projects/commits_controller_spec.rb @@ -67,6 +67,29 @@ RSpec.describe Projects::CommitsController do end end + context "with an invalid limit" do + let(:id) { "master/README.md" } + + it "uses the default limit" do + expect_any_instance_of(Repository).to receive(:commits).with( + "master", + path: "README.md", + limit: described_class::COMMITS_DEFAULT_LIMIT, + offset: 0 + ).and_call_original + + get(:show, + params: { + namespace_id: project.namespace, + project_id: project, + id: id, + limit: "foo" + }) + + expect(response).to be_successful + end + end + context "when the ref name ends in .atom" do context "when the ref does not exist with the suffix" do before do diff --git a/spec/controllers/projects/hooks_controller_spec.rb b/spec/controllers/projects/hooks_controller_spec.rb index 17baf38ef32..2ab18ccddbf 100644 --- a/spec/controllers/projects/hooks_controller_spec.rb +++ b/spec/controllers/projects/hooks_controller_spec.rb @@ -109,7 +109,7 @@ RSpec.describe Projects::HooksController do describe '#test' do let(:hook) { create(:project_hook, project: project) } - context 'when the endpoint receives requests above the limit' do + context 'when the endpoint receives requests above the limit', :freeze_time, :clean_gitlab_redis_rate_limiting do before do allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits) .and_return(project_testing_hook: { threshold: 1, interval: 1.minute }) diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 0b3bd4d78ac..68cccfa8bde 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1084,28 +1084,30 @@ RSpec.describe Projects::IssuesController do end context 'real-time sidebar feature flag' do - using RSpec::Parameterized::TableSyntax - let_it_be(:project) { create(:project, :public) } let_it_be(:issue) { create(:issue, project: project) } - where(:action_cable_in_app_enabled, :feature_flag_enabled, :gon_feature_flag) do - true | true | true - true | false | true - false | true | true - false | false | false + context 'when enabled' do + before do + stub_feature_flags(real_time_issue_sidebar: true) + end + + it 'pushes the correct value to the frontend' do + go(id: issue.to_param) + + expect(Gon.features).to include('realTimeIssueSidebar' => true) + end end - with_them do + context 'when disabled' do before do - expect(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(action_cable_in_app_enabled) - stub_feature_flags(real_time_issue_sidebar: feature_flag_enabled) + stub_feature_flags(real_time_issue_sidebar: false) end - it 'broadcasts to the issues channel based on ActionCable and feature flag values' do + it 'pushes the correct value to the frontend' do go(id: issue.to_param) - expect(Gon.features).to include('realTimeIssueSidebar' => gon_feature_flag) + expect(Gon.features).to include('realTimeIssueSidebar' => false) end end end @@ -1406,14 +1408,14 @@ RSpec.describe Projects::IssuesController do end end - context 'when the endpoint receives requests above the limit' do + context 'when the endpoint receives requests above the limit', :freeze_time, :clean_gitlab_redis_rate_limiting do before do - stub_application_setting(issues_create_limit: 5) + stub_application_setting(issues_create_limit: 1) end context 'when issue creation limits imposed' do it 'prevents from creating more issues', :request_store do - 5.times { post_new_issue } + post_new_issue expect { post_new_issue } .to change { Gitlab::GitalyClient.get_request_count }.by(1) # creates 1 projects and 0 issues @@ -1440,7 +1442,7 @@ RSpec.describe Projects::IssuesController do project.add_developer(user) sign_in(user) - 6.times do + 2.times do post :create, params: { namespace_id: project.namespace.to_param, project_id: project, diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index 06c29e767ad..ed68d6a87b8 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -463,12 +463,25 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do end end - context 'when job has trace' do + context 'when job has live trace' do let(:job) { create(:ci_build, :running, :trace_live, pipeline: pipeline) } - it "has_trace is true" do + it 'has_trace is true' do get_show_json + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('job/job_details') + expect(json_response['has_trace']).to be true + end + end + + context 'when has live trace and unarchived artifact' do + let(:job) { create(:ci_build, :running, :trace_live, :unarchived_trace_artifact, pipeline: pipeline) } + + it 'has_trace is true' do + get_show_json + + expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('job/job_details') expect(json_response['has_trace']).to be true end @@ -631,15 +644,25 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do end end - context 'when job has a trace' do + context 'when job has a live trace' do let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) } - it 'returns a trace' do - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('job/build_trace') - expect(json_response['id']).to eq job.id - expect(json_response['status']).to eq job.status - expect(json_response['lines']).to eq [{ 'content' => [{ 'text' => 'BUILD TRACE' }], 'offset' => 0 }] + shared_examples_for 'returns trace' do + it 'returns a trace' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('job/build_trace') + expect(json_response['id']).to eq job.id + expect(json_response['status']).to eq job.status + expect(json_response['lines']).to match_array [{ 'content' => [{ 'text' => 'BUILD TRACE' }], 'offset' => 0 }] + end + end + + it_behaves_like 'returns trace' + + context 'when job has unarchived artifact' do + let(:job) { create(:ci_build, :trace_live, :unarchived_trace_artifact, pipeline: pipeline) } + + it_behaves_like 'returns trace' end context 'when job is running' do @@ -1055,9 +1078,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do post_erase end - context 'when job is erasable' do - let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline) } - + shared_examples_for 'erases' do it 'redirects to the erased job page' do expect(response).to have_gitlab_http_status(:found) expect(response).to redirect_to(namespace_project_job_path(id: job.id)) @@ -1073,7 +1094,19 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do end end - context 'when job is not erasable' do + context 'when job is successful and has artifacts' do + let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline) } + + it_behaves_like 'erases' + end + + context 'when job has live trace and unarchived artifact' do + let(:job) { create(:ci_build, :success, :trace_live, :unarchived_trace_artifact, pipeline: pipeline) } + + it_behaves_like 'erases' + end + + context 'when job is erased' do let(:job) { create(:ci_build, :erased, pipeline: pipeline) } it 'returns unprocessable_entity' do @@ -1165,16 +1198,26 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do end end - context "when job has a trace file" do + context 'when job has a live trace' do let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) } - it 'sends a trace file' do - response = subject + shared_examples_for 'sends live trace' do + it 'sends a trace file' do + response = subject - expect(response).to have_gitlab_http_status(:ok) - expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") - expect(response.headers["Content-Disposition"]).to match(/^inline/) - expect(response.body).to eq("BUILD TRACE") + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") + expect(response.headers["Content-Disposition"]).to match(/^inline/) + expect(response.body).to eq("BUILD TRACE") + end + end + + it_behaves_like 'sends live trace' + + context 'and when job has unarchived artifact' do + let(:job) { create(:ci_build, :trace_live, :unarchived_trace_artifact, pipeline: pipeline) } + + it_behaves_like 'sends live trace' end end diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index 3d7636b1f30..5b1c6777523 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -86,10 +86,11 @@ RSpec.describe Projects::MergeRequests::DiffsController do let(:project) { create(:project, :repository) } let(:user) { create(:user) } + let(:maintainer) { true } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } before do - project.add_maintainer(user) + project.add_maintainer(user) if maintainer sign_in(user) end @@ -383,8 +384,9 @@ RSpec.describe Projects::MergeRequests::DiffsController do end context 'when the user cannot view the merge request' do + let(:maintainer) { false } + before do - project.team.truncate diff_for_path(old_path: existing_path, new_path: existing_path) end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 438fc2f2106..46b332a8938 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -10,7 +10,8 @@ RSpec.describe Projects::MergeRequestsController do let_it_be_with_reload(:project_public_with_private_builds) { create(:project, :repository, :public, :builds_private) } let(:user) { project.owner } - let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } + let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: merge_request_source_project, allow_collaboration: false) } + let(:merge_request_source_project) { project } before do sign_in(user) @@ -506,6 +507,7 @@ RSpec.describe Projects::MergeRequestsController do end it 'starts the merge immediately with permitted params' do + allow(MergeWorker).to receive(:with_status).and_return(MergeWorker) expect(MergeWorker).to receive(:perform_async).with(merge_request.id, anything, { 'sha' => merge_request.diff_head_sha }) merge_with_sha @@ -2073,19 +2075,21 @@ RSpec.describe Projects::MergeRequestsController do end describe 'POST #rebase' do - let(:viewer) { user } - def post_rebase post :rebase, params: { namespace_id: project.namespace, project_id: project, id: merge_request } end + before do + allow(RebaseWorker).to receive(:with_status).and_return(RebaseWorker) + end + def expect_rebase_worker_for(user) expect(RebaseWorker).to receive(:perform_async).with(merge_request.id, user.id, false) end context 'successfully' do it 'enqeues a RebaseWorker' do - expect_rebase_worker_for(viewer) + expect_rebase_worker_for(user) post_rebase @@ -2108,17 +2112,17 @@ RSpec.describe Projects::MergeRequestsController do context 'with a forked project' do let(:forked_project) { fork_project(project, fork_owner, repository: true) } let(:fork_owner) { create(:user) } + let(:merge_request_source_project) { forked_project } - before do - project.add_developer(fork_owner) + context 'user cannot push to source branch' do + before do + project.add_developer(fork_owner) - merge_request.update!(source_project: forked_project) - forked_project.add_reporter(user) - end + forked_project.add_reporter(user) + end - context 'user cannot push to source branch' do it 'returns 404' do - expect_rebase_worker_for(viewer).never + expect_rebase_worker_for(user).never post_rebase diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index d92862f0ca3..66af546b113 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -1007,6 +1007,35 @@ RSpec.describe Projects::NotesController do end end + describe 'GET outdated_line_change' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + id: note, + format: 'json' + } + end + + before do + service = double + allow(service).to receive(:execute).and_return([{ line_text: 'Test' }]) + allow(MergeRequests::OutdatedDiscussionDiffLinesService).to receive(:new).once.and_return(service) + + sign_in(user) + project.add_developer(user) + end + + it "successfully renders expected JSON response" do + get :outdated_line_change, params: request_params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an(Array) + expect(json_response.count).to eq(1) + expect(json_response.first).to include({ "line_text" => "Test" }) + end + end + # Convert a time to an integer number of microseconds def microseconds(time) (time.to_i * 1_000_000) + time.usec diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 1354e894872..14c613ff9c4 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -44,7 +44,7 @@ RSpec.describe Projects::PipelinesController do end end - it 'does not execute N+1 queries' do + it 'does not execute N+1 queries', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/345470' do get_pipelines_index_json control_count = ActiveRecord::QueryRecorder.new do diff --git a/spec/controllers/projects/prometheus/alerts_controller_spec.rb b/spec/controllers/projects/prometheus/alerts_controller_spec.rb index 46de8aa4baf..d66ad445c32 100644 --- a/spec/controllers/projects/prometheus/alerts_controller_spec.rb +++ b/spec/controllers/projects/prometheus/alerts_controller_spec.rb @@ -160,7 +160,9 @@ RSpec.describe Projects::Prometheus::AlertsController do end describe 'POST #notify' do - let(:service_response) { ServiceResponse.success } + let(:alert_1) { build(:alert_management_alert, :prometheus, project: project) } + let(:alert_2) { build(:alert_management_alert, :prometheus, project: project) } + let(:service_response) { ServiceResponse.success(payload: { alerts: [alert_1, alert_2] }) } let(:notify_service) { instance_double(Projects::Prometheus::Alerts::NotifyService, execute: service_response) } before do @@ -173,10 +175,15 @@ RSpec.describe Projects::Prometheus::AlertsController do end it 'returns ok if notification succeeds' do - expect(notify_service).to receive(:execute).and_return(ServiceResponse.success) + expect(notify_service).to receive(:execute).and_return(service_response) post :notify, params: project_params, session: { as: :json } + expect(json_response).to contain_exactly( + { 'iid' => alert_1.iid, 'title' => alert_1.title }, + { 'iid' => alert_2.iid, 'title' => alert_2.title } + ) + expect(response).to have_gitlab_http_status(:ok) end diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb index a1e36ec5c4c..120020273f9 100644 --- a/spec/controllers/projects/releases_controller_spec.rb +++ b/spec/controllers/projects/releases_controller_spec.rb @@ -207,7 +207,18 @@ RSpec.describe Projects::ReleasesController do let(:project) { private_project } let(:user) { guest } - it_behaves_like 'not found' + it_behaves_like 'successful request' + end + + context 'when user is an external user for the project' do + let(:project) { private_project } + let(:user) { create(:user) } + + it 'behaves like not found' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end end end diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 482ba552f8f..29988da6e60 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Projects::ServicesController do project.add_maintainer(user) end - it_behaves_like IntegrationsActions do + it_behaves_like Integrations::Actions do let(:integration_attributes) { { project: project } } let(:routing_params) do @@ -254,7 +254,7 @@ RSpec.describe Projects::ServicesController do let_it_be(:project) { create(:project, group: group) } let_it_be(:jira_integration) { create(:jira_integration, project: project) } - let(:group_integration) { create(:jira_integration, group: group, project: nil, url: 'http://group.com', password: 'group') } + let(:group_integration) { create(:jira_integration, :group, group: group, url: 'http://group.com', password: 'group') } let(:integration_params) { { inherit_from_id: group_integration.id, url: 'http://custom.com', password: 'custom' } } it 'ignores submitted params and inherits group settings' do @@ -269,7 +269,7 @@ RSpec.describe Projects::ServicesController do context 'when param `inherit_from_id` is set to an unrelated group' do let_it_be(:group) { create(:group) } - let(:group_integration) { create(:jira_integration, group: group, project: nil, url: 'http://group.com', password: 'group') } + let(:group_integration) { create(:jira_integration, :group, group: group, url: 'http://group.com', password: 'group') } let(:integration_params) { { inherit_from_id: group_integration.id, url: 'http://custom.com', password: 'custom' } } it 'ignores the param and saves the submitted settings' do diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb index d0719643b7f..0045c0a484b 100644 --- a/spec/controllers/projects/tags_controller_spec.rb +++ b/spec/controllers/projects/tags_controller_spec.rb @@ -25,7 +25,7 @@ RSpec.describe Projects::TagsController do with_them do it 'returns 503 status code' do expect_next_instance_of(TagsFinder) do |finder| - expect(finder).to receive(:execute).and_return([[], Gitlab::Git::CommandError.new]) + expect(finder).to receive(:execute).and_raise(Gitlab::Git::CommandError) end get :index, params: { namespace_id: project.namespace.to_param, project_id: project }, format: format diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index b34cfedb767..dafa639a2d5 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -213,21 +213,6 @@ RSpec.describe ProjectsController do before do sign_in(user) - - allow(controller).to receive(:record_experiment_user) - end - - context 'when user can push to default branch', :experiment do - let(:user) { empty_project.owner } - - it 'creates an "view_project_show" experiment tracking event' do - expect(experiment(:empty_repo_upload)).to track( - :view_project_show, - property: 'empty' - ).on_next_instance - - get :show, params: { namespace_id: empty_project.namespace, id: empty_project } - end end User.project_views.keys.each do |project_view| @@ -1158,6 +1143,22 @@ RSpec.describe ProjectsController do expect(json_response["Commits"]).to include("123456") end + 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) + end + end + + it 'gets an empty list of tags' 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") + end + end + context "when preferred language is Japanese" do before do user.update!(preferred_language: 'ja') diff --git a/spec/controllers/registrations/welcome_controller_spec.rb b/spec/controllers/registrations/welcome_controller_spec.rb index 034c9b3d1c0..0a1e6b8ec8f 100644 --- a/spec/controllers/registrations/welcome_controller_spec.rb +++ b/spec/controllers/registrations/welcome_controller_spec.rb @@ -97,6 +97,16 @@ RSpec.describe Registrations::WelcomeController do expect(subject).to redirect_to(dashboard_projects_path) end end + + context 'when tasks to be done are assigned' do + let!(:member1) { create(:group_member, user: user, tasks_to_be_done: %w(ci code)) } + + before do + stub_experiments(invite_members_for_task: true) + end + + it { is_expected.to redirect_to(issues_dashboard_path(assignee_username: user.username)) } + end end end end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index a25c597edb2..baf500c2b57 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -499,13 +499,12 @@ RSpec.describe RegistrationsController do expect(User.last.name).to eq("#{base_user_params[:first_name]} #{base_user_params[:last_name]}") end - it 'sets the username and caller_id in the context' do + it 'sets the caller_id in the context' do expect(controller).to receive(:create).and_wrap_original do |m, *args| m.call(*args) expect(Gitlab::ApplicationContext.current) - .to include('meta.user' => base_user_params[:username], - 'meta.caller_id' => 'RegistrationsController#create') + .to include('meta.caller_id' => 'RegistrationsController#create') end subject diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 5eccb0b46ef..521b4cd4002 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -18,8 +18,8 @@ RSpec.describe 'Database schema' do approvals: %w[user_id], approver_groups: %w[target_id], approvers: %w[target_id user_id], - analytics_cycle_analytics_merge_request_stage_events: %w[author_id group_id merge_request_id milestone_id project_id stage_event_hash_id], - analytics_cycle_analytics_issue_stage_events: %w[author_id group_id issue_id milestone_id project_id stage_event_hash_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], award_emoji: %w[awardable_id user_id], aws_roles: %w[role_external_id], @@ -29,6 +29,7 @@ RSpec.describe 'Database schema' do ci_builds: %w[erased_by_id runner_id trigger_request_id user_id], ci_namespace_monthly_usages: %w[namespace_id], ci_pipelines: %w[user_id], + ci_pipeline_chat_data: %w[chat_name_id], # it uses the loose foreign key featue ci_runner_projects: %w[runner_id], ci_trigger_requests: %w[commit_id], cluster_providers_aws: %w[security_group_id vpc_id access_key_id], @@ -48,7 +49,6 @@ RSpec.describe 'Database schema' do geo_node_statuses: %w[last_event_id cursor_last_event_id], geo_nodes: %w[oauth_application_id], geo_repository_deleted_events: %w[project_id], - geo_upload_deleted_events: %w[upload_id model_id], gitlab_subscription_histories: %w[gitlab_subscription_id hosted_plan_id namespace_id], identities: %w[user_id], import_failures: %w[project_id], @@ -66,7 +66,6 @@ RSpec.describe 'Database schema' do oauth_access_grants: %w[resource_owner_id application_id], oauth_access_tokens: %w[resource_owner_id application_id], oauth_applications: %w[owner_id], - open_project_tracker_data: %w[closed_status_id], packages_build_infos: %w[pipeline_id], packages_package_file_build_infos: %w[pipeline_id], product_analytics_events_experimental: %w[event_id txn_id user_id], @@ -210,7 +209,7 @@ RSpec.describe 'Database schema' do # We are skipping GEO models for now as it adds up complexity describe 'for jsonb columns' do - it 'uses json schema validator' do + it 'uses json schema validator', :eager_load do columns_name_with_jsonb.each do |hash| next if models_by_table_name[hash["table_name"]].nil? diff --git a/spec/experiments/change_continuous_onboarding_link_urls_experiment_spec.rb b/spec/experiments/change_continuous_onboarding_link_urls_experiment_spec.rb new file mode 100644 index 00000000000..815aaf7c397 --- /dev/null +++ b/spec/experiments/change_continuous_onboarding_link_urls_experiment_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ChangeContinuousOnboardingLinkUrlsExperiment, :snowplow do + before do + stub_experiments(change_continuous_onboarding_link_urls: 'control') + end + + describe '#track' do + context 'when no namespace has been set' do + it 'tracks the action as normal' do + subject.track(:some_action) + + expect_snowplow_event( + category: subject.name, + action: 'some_action', + namespace: nil, + context: [ + { + schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', + data: an_instance_of(Hash) + } + ] + ) + end + end + + context 'when a namespace has been set' do + let_it_be(:namespace) { create(:namespace) } + + before do + subject.namespace = namespace + end + + it 'tracks the action and merges the namespace into the event args' do + subject.track(:some_action) + + expect_snowplow_event( + category: subject.name, + action: 'some_action', + namespace: namespace, + context: [ + { + schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', + data: an_instance_of(Hash) + } + ] + ) + end + end + end +end diff --git a/spec/experiments/empty_repo_upload_experiment_spec.rb b/spec/experiments/empty_repo_upload_experiment_spec.rb deleted file mode 100644 index 10cbedbe8ba..00000000000 --- a/spec/experiments/empty_repo_upload_experiment_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe EmptyRepoUploadExperiment, :experiment do - subject { described_class.new(project: project) } - - let(:project) { create(:project, :repository) } - - describe '#track_initial_write' do - context 'when experiment is turned on' do - before do - stub_experiments(empty_repo_upload: :control) - end - - it "tracks an event for the first commit on a project" do - expect(subject).to receive(:commit_count_for).with(project, max_count: described_class::INITIAL_COMMIT_COUNT, experiment: 'empty_repo_upload').and_return(1) - - expect(subject).to receive(:track).with(:initial_write, project: project).and_call_original - - subject.track_initial_write - end - - it "doesn't track an event for projects with a commit count more than 1" do - expect(subject).to receive(:commit_count_for).and_return(2) - - expect(subject).not_to receive(:track) - - subject.track_initial_write - end - - it "doesn't track if the project is older" do - expect(project).to receive(:created_at).and_return(described_class::TRACKING_START_DATE - 1.minute) - - expect(subject).not_to receive(:track) - - subject.track_initial_write - end - end - - context 'when experiment is turned off' do - it "doesn't track when we generally shouldn't" do - expect(subject).not_to receive(:track) - - subject.track_initial_write - end - end - end -end diff --git a/spec/factories/analytics/cycle_analytics/issue_stage_events.rb b/spec/factories/analytics/cycle_analytics/issue_stage_events.rb new file mode 100644 index 00000000000..8ad88152611 --- /dev/null +++ b/spec/factories/analytics/cycle_analytics/issue_stage_events.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :cycle_analytics_issue_stage_event, class: 'Analytics::CycleAnalytics::IssueStageEvent' do + sequence(:stage_event_hash_id) { |n| n } + sequence(:issue_id) { 0 } + sequence(:group_id) { 0 } + sequence(:project_id) { 0 } + + start_event_timestamp { 3.weeks.ago.to_date } + end_event_timestamp { 2.weeks.ago.to_date } + end +end diff --git a/spec/factories/analytics/cycle_analytics/merge_request_stage_events.rb b/spec/factories/analytics/cycle_analytics/merge_request_stage_events.rb new file mode 100644 index 00000000000..d8fa43b024f --- /dev/null +++ b/spec/factories/analytics/cycle_analytics/merge_request_stage_events.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :cycle_analytics_merge_request_stage_event, class: 'Analytics::CycleAnalytics::MergeRequestStageEvent' do + sequence(:stage_event_hash_id) { |n| n } + sequence(:merge_request_id) { 0 } + sequence(:group_id) { 0 } + sequence(:project_id) { 0 } + + start_event_timestamp { 3.weeks.ago.to_date } + end_event_timestamp { 2.weeks.ago.to_date } + end +end diff --git a/spec/factories/authentication_event.rb b/spec/factories/authentication_event.rb index ff539c6f5c4..e02698fac38 100644 --- a/spec/factories/authentication_event.rb +++ b/spec/factories/authentication_event.rb @@ -7,5 +7,13 @@ FactoryBot.define do user_name { 'Jane Doe' } ip_address { '127.0.0.1' } result { :failed } + + trait :successful do + result { :success } + end + + trait :failed do + result { :failed } + end end end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 1108c606df3..98023334894 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -282,6 +282,12 @@ FactoryBot.define do end end + trait :unarchived_trace_artifact do + after(:create) do |build, evaluator| + create(:ci_job_artifact, :unarchived_trace_artifact, job: build) + end + end + trait :trace_with_duplicate_sections do after(:create) do |build, evaluator| trace = File.binread( @@ -443,7 +449,7 @@ FactoryBot.define do options do { image: { name: 'ruby:2.7', entrypoint: '/bin/sh' }, - services: ['postgres', { name: 'docker:stable-dind', entrypoint: '/bin/sh', command: 'sleep 30', alias: 'docker' }], + 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), artifacts: { diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb index 2f4eb99a073..223de873a04 100644 --- a/spec/factories/ci/job_artifacts.rb +++ b/spec/factories/ci/job_artifacts.rb @@ -87,6 +87,17 @@ FactoryBot.define do end end + trait :unarchived_trace_artifact do + file_type { :trace } + file_format { :raw } + + after(:build) do |artifact, evaluator| + file = double('file', path: '/path/to/job.log') + artifact.file = file + allow(artifact.file).to receive(:file).and_return(CarrierWave::SanitizedFile.new(file)) + end + end + trait :junit do file_type { :junit } file_format { :gzip } diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index ae3404a41a2..1d25964a4be 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -213,6 +213,14 @@ FactoryBot.define do end end + trait :with_persisted_artifacts do + status { :success } + + after(:create) do |pipeline, evaluator| + pipeline.builds << create(:ci_build, :artifacts, pipeline: pipeline, project: pipeline.project) + end + end + trait :with_job do after(:build) do |pipeline, evaluator| pipeline.builds << build(:ci_build, pipeline: pipeline, project: pipeline.project) diff --git a/spec/factories/ci/reports/security/findings.rb b/spec/factories/ci/reports/security/findings.rb index e3971bc48f3..8a39fce971f 100644 --- a/spec/factories/ci/reports/security/findings.rb +++ b/spec/factories/ci/reports/security/findings.rb @@ -9,7 +9,7 @@ FactoryBot.define do metadata_version { 'sast:1.0' } name { 'Cipher with no integrity' } report_type { :sast } - raw_metadata do + original_data do { description: "The cipher does not provide data integrity update 1", solution: "GCM mode introduces an HMAC into the resulting encrypted data, providing integrity of the result.", @@ -26,7 +26,7 @@ FactoryBot.define do url: "https://crypto.stackexchange.com/questions/31428/pbewithmd5anddes-cipher-does-not-check-for-integrity-first" } ] - }.to_json + }.deep_stringify_keys end scanner factory: :ci_reports_security_scanner severity { :high } diff --git a/spec/factories/ci/runner_namespaces.rb b/spec/factories/ci/runner_namespaces.rb index a5060d196ca..e3cebed789b 100644 --- a/spec/factories/ci/runner_namespaces.rb +++ b/spec/factories/ci/runner_namespaces.rb @@ -2,7 +2,14 @@ FactoryBot.define do factory :ci_runner_namespace, class: 'Ci::RunnerNamespace' do - runner factory: [:ci_runner, :group] group + + after(:build) do |runner_namespace, evaluator| + unless runner_namespace.runner.present? + runner_namespace.runner = build( + :ci_runner, :group, runner_namespaces: [runner_namespace] + ) + end + end end end diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index d0853df4e4b..6665b7b76a0 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -11,6 +11,7 @@ FactoryBot.define do runner_type { :instance_type } transient do + groups { [] } projects { [] } end @@ -18,6 +19,10 @@ FactoryBot.define do evaluator.projects.each do |proj| runner.runner_projects << build(:ci_runner_project, project: proj) end + + evaluator.groups.each do |group| + runner.runner_namespaces << build(:ci_runner_namespace, namespace: group) + end end trait :online do @@ -32,7 +37,9 @@ FactoryBot.define do runner_type { :group_type } after(:build) do |runner, evaluator| - runner.groups << build(:group) if runner.groups.empty? + if runner.runner_namespaces.empty? + runner.runner_namespaces << build(:ci_runner_namespace) + end end end diff --git a/spec/factories/customer_relations/issue_customer_relations_contacts.rb b/spec/factories/customer_relations/issue_customer_relations_contacts.rb new file mode 100644 index 00000000000..6a4fecfb3cf --- /dev/null +++ b/spec/factories/customer_relations/issue_customer_relations_contacts.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :issue_customer_relations_contact, class: 'CustomerRelations::IssueContact' do + issue { association(:issue, project: project) } + contact { association(:contact, group: group) } + + transient do + group { association(:group) } + project { association(:project, group: group) } + end + + trait :for_contact do + issue { association(:issue, project: project) } + contact { raise ArgumentError, '`contact` is manadatory' } + + transient do + project { association(:project, group: contact.group) } + end + end + + trait :for_issue do + issue { raise ArgumentError, '`issue` is manadatory' } + contact { association(:contact, group: issue.project.group) } + end + end +end diff --git a/spec/factories/design_management/designs.rb b/spec/factories/design_management/designs.rb index c23a67fe95b..56a1b55b969 100644 --- a/spec/factories/design_management/designs.rb +++ b/spec/factories/design_management/designs.rb @@ -39,7 +39,7 @@ FactoryBot.define do sha = commit_version[action] version = DesignManagement::Version.new(sha: sha, issue: issue, author: evaluator.author) version.save!(validate: false) # We need it to have an ID, validate later - Gitlab::Database.main.bulk_insert(dv_table_name, [action.row_attrs(version)]) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(dv_table_name, [action.row_attrs(version)]) # rubocop:disable Gitlab/BulkInsert end # always a creation diff --git a/spec/factories/error_tracking/error_event.rb b/spec/factories/error_tracking/error_event.rb index 9620e3999d6..83f38150b11 100644 --- a/spec/factories/error_tracking/error_event.rb +++ b/spec/factories/error_tracking/error_event.rb @@ -63,5 +63,9 @@ FactoryBot.define do level { 'error' } occurred_at { Time.now.iso8601 } payload { Gitlab::Json.parse(File.read(Rails.root.join('spec/fixtures/', 'error_tracking/parsed_event.json'))) } + + trait :browser do + payload { Gitlab::Json.parse(File.read(Rails.root.join('spec/fixtures/', 'error_tracking/browser_event.json'))) } + end end end diff --git a/spec/factories/gitlab/database/reindexing/queued_action.rb b/spec/factories/gitlab/database/reindexing/queued_action.rb new file mode 100644 index 00000000000..30e12a81272 --- /dev/null +++ b/spec/factories/gitlab/database/reindexing/queued_action.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :reindexing_queued_action, class: 'Gitlab::Database::Reindexing::QueuedAction' do + association :index, factory: :postgres_index + + state { Gitlab::Database::Reindexing::QueuedAction.states[:queued] } + index_identifier { index.identifier } + end +end diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb index 37ddbc09616..ab2321c81c4 100644 --- a/spec/factories/group_members.rb +++ b/spec/factories/group_members.rb @@ -34,5 +34,18 @@ FactoryBot.define do access_level { GroupMember::MINIMAL_ACCESS } end + + transient do + tasks_to_be_done { [] } + end + + after(:build) do |group_member, evaluator| + if evaluator.tasks_to_be_done.present? + build(:member_task, + member: group_member, + project: build(:project, namespace: group_member.source), + tasks_to_be_done: evaluator.tasks_to_be_done) + end + end end end diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index 63f85c04ac7..76415f82ed0 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -111,6 +111,12 @@ FactoryBot.define do end end + factory :shimo_integration, class: 'Integrations::Shimo' do + project + active { true } + external_wiki_url { 'https://shimo.example.com/desktop' } + end + factory :confluence_integration, class: 'Integrations::Confluence' do project active { true } @@ -216,6 +222,11 @@ FactoryBot.define do template { true } end + trait :group do + group + project { nil } + end + trait :instance do project { nil } instance { true } diff --git a/spec/factories/member_tasks.rb b/spec/factories/member_tasks.rb new file mode 100644 index 00000000000..133ccce5f8a --- /dev/null +++ b/spec/factories/member_tasks.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :member_task do + member { association(:group_member, :invited) } + project { association(:project, namespace: member.source) } + tasks_to_be_done { [:ci, :code] } + end +end diff --git a/spec/factories/namespaces/project_namespaces.rb b/spec/factories/namespaces/project_namespaces.rb index ca9fc5f8768..6bf17088741 100644 --- a/spec/factories/namespaces/project_namespaces.rb +++ b/spec/factories/namespaces/project_namespaces.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :project_namespace, class: 'Namespaces::ProjectNamespace' do - project + association :project, factory: :project, strategy: :build parent { project.namespace } visibility_level { project.visibility_level } name { project.name } diff --git a/spec/factories/operations/feature_flags/strategy.rb b/spec/factories/operations/feature_flags/strategy.rb index bdb5d9f0f3c..8d04b6d25aa 100644 --- a/spec/factories/operations/feature_flags/strategy.rb +++ b/spec/factories/operations/feature_flags/strategy.rb @@ -5,5 +5,37 @@ FactoryBot.define do association :feature_flag, factory: :operations_feature_flag name { "default" } parameters { {} } + + trait :default do + name { "default" } + parameters { {} } + end + + trait :gitlab_userlist do + association :user_list, factory: :operations_feature_flag_user_list + name { "gitlabUserList" } + parameters { {} } + end + + trait :flexible_rollout do + name { "flexibleRollout" } + parameters do + { + groupId: 'default', + rollout: '10', + stickiness: 'default' + } + end + end + + trait :gradual_rollout do + name { "gradualRolloutUserId" } + parameters { { percentage: '10', groupId: 'default' } } + end + + trait :userwithid do + name { "userWithId" } + parameters { { userIds: 'user1' } } + end end end diff --git a/spec/factories/packages/helm/file_metadatum.rb b/spec/factories/packages/helm/file_metadatum.rb index 3f599b5d5c0..590956e5d49 100644 --- a/spec/factories/packages/helm/file_metadatum.rb +++ b/spec/factories/packages/helm/file_metadatum.rb @@ -9,7 +9,11 @@ FactoryBot.define do package_file { association(:helm_package_file, without_loaded_metadatum: true) } sequence(:channel) { |n| "#{FFaker::Lorem.word}-#{n}" } metadata do - { 'name': package_file.package.name, 'version': package_file.package.version, 'apiVersion': 'v2' }.tap do |defaults| + { + 'name': package_file.package.name, + 'version': package_file.package.version, + 'apiVersion': 'v2' + }.tap do |defaults| defaults['description'] = description if description end end diff --git a/spec/factories/packages/npm/metadata.rb b/spec/factories/packages/npm/metadata.rb new file mode 100644 index 00000000000..c8acaa10199 --- /dev/null +++ b/spec/factories/packages/npm/metadata.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :npm_metadatum, class: 'Packages::Npm::Metadatum' do + package { association(:npm_package) } + + package_json do + { + 'name': package.name, + 'version': package.version, + 'dist': { + 'tarball': 'http://localhost/tarball.tgz', + 'shasum': '1234567890' + } + } + end + end +end diff --git a/spec/factories/project_members.rb b/spec/factories/project_members.rb index 3e83ab7118c..f2dedc178c7 100644 --- a/spec/factories/project_members.rb +++ b/spec/factories/project_members.rb @@ -23,5 +23,15 @@ FactoryBot.define do trait :blocked do after(:build) { |project_member, _| project_member.user.block! } end + + transient do + tasks_to_be_done { [] } + end + + after(:build) do |project_member, evaluator| + if evaluator.tasks_to_be_done.present? + build(:member_task, member: project_member, project: project_member.source, tasks_to_be_done: evaluator.tasks_to_be_done) + end + end end end diff --git a/spec/factories/user_highest_roles.rb b/spec/factories/user_highest_roles.rb index 761a8b6c583..ee5794b55fb 100644 --- a/spec/factories/user_highest_roles.rb +++ b/spec/factories/user_highest_roles.rb @@ -5,10 +5,10 @@ FactoryBot.define do highest_access_level { nil } user - trait(:guest) { highest_access_level { GroupMember::GUEST } } - trait(:reporter) { highest_access_level { GroupMember::REPORTER } } - trait(:developer) { highest_access_level { GroupMember::DEVELOPER } } - trait(:maintainer) { highest_access_level { GroupMember::MAINTAINER } } - trait(:owner) { highest_access_level { GroupMember::OWNER } } + trait(:guest) { highest_access_level { GroupMember::GUEST } } + trait(:reporter) { highest_access_level { GroupMember::REPORTER } } + trait(:developer) { highest_access_level { GroupMember::DEVELOPER } } + trait(:maintainer) { highest_access_level { GroupMember::MAINTAINER } } + trait(:owner) { highest_access_level { GroupMember::OWNER } } end end diff --git a/spec/factories/users/credit_card_validations.rb b/spec/factories/users/credit_card_validations.rb index 09940347708..509e86e7bd3 100644 --- a/spec/factories/users/credit_card_validations.rb +++ b/spec/factories/users/credit_card_validations.rb @@ -3,7 +3,10 @@ FactoryBot.define do factory :credit_card_validation, class: 'Users::CreditCardValidation' do user - - credit_card_validated_at { Time.current } + sequence(:credit_card_validated_at) { |n| Time.current + n } + expiration_date { 1.year.from_now.end_of_month } + last_digits { 10 } + holder_name { 'John Smith' } + network { 'AmericanExpress' } end end diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb index 7dc38b25fac..811ed18dce3 100644 --- a/spec/factories_spec.rb +++ b/spec/factories_spec.rb @@ -22,6 +22,8 @@ RSpec.describe 'factories' do [:debian_project_component_file, :object_storage], [:debian_project_distribution, :object_storage], [:debian_file_metadatum, :unknown], + [:issue_customer_relations_contact, :for_contact], + [:issue_customer_relations_contact, :for_issue], [:package_file, :object_storage], [:pages_domain, :without_certificate], [:pages_domain, :without_key], @@ -72,6 +74,8 @@ RSpec.describe 'factories' do fork_network_member group_member import_state + issue_customer_relations_contact + member_task milestone_release namespace project_broken_repo diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb index cb69eac8035..0785c736cfb 100644 --- a/spec/features/admin/admin_appearance_spec.rb +++ b/spec/features/admin/admin_appearance_spec.rb @@ -94,7 +94,7 @@ RSpec.describe 'Admin Appearance' do sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect_custom_new_project_appearance(appearance) end diff --git a/spec/features/admin/admin_deploy_keys_spec.rb b/spec/features/admin/admin_deploy_keys_spec.rb index c326d0fd741..53caf0fac33 100644 --- a/spec/features/admin/admin_deploy_keys_spec.rb +++ b/spec/features/admin/admin_deploy_keys_spec.rb @@ -3,11 +3,13 @@ require 'spec_helper' RSpec.describe 'admin deploy keys' do + let_it_be(:admin) { create(:admin) } + let!(:deploy_key) { create(:deploy_key, public: true) } let!(:another_deploy_key) { create(:another_deploy_key, public: true) } before do - admin = create(:admin) + stub_feature_flags(admin_deploy_keys_vue: false) sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) end @@ -15,7 +17,7 @@ RSpec.describe 'admin deploy keys' do it 'show all public deploy keys' do visit admin_deploy_keys_path - page.within(find('.deploy-keys-list', match: :first)) do + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do expect(page).to have_content(deploy_key.title) expect(page).to have_content(another_deploy_key.title) end @@ -26,7 +28,7 @@ RSpec.describe 'admin deploy keys' do visit admin_deploy_keys_path - page.within(find('.deploy-keys-list', match: :first)) do + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do expect(page).to have_content(write_key.project.full_name) end end @@ -46,7 +48,7 @@ RSpec.describe 'admin deploy keys' do expect(current_path).to eq admin_deploy_keys_path - page.within(find('.deploy-keys-list', match: :first)) do + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do expect(page).to have_content('laptop') end end @@ -64,7 +66,7 @@ RSpec.describe 'admin deploy keys' do expect(current_path).to eq admin_deploy_keys_path - page.within(find('.deploy-keys-list', match: :first)) do + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do expect(page).to have_content('new-title') end end @@ -79,9 +81,23 @@ RSpec.describe 'admin deploy keys' do find('tr', text: deploy_key.title).click_link('Remove') expect(current_path).to eq admin_deploy_keys_path - page.within(find('.deploy-keys-list', match: :first)) do + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do expect(page).not_to have_content(deploy_key.title) end end end + + context 'when `admin_deploy_keys_vue` feature flag is enabled', :js do + before do + stub_feature_flags(admin_deploy_keys_vue: true) + + visit admin_deploy_keys_path + end + + it 'renders the Vue app', :aggregate_failures do + expect(page).to have_content('Public deploy keys') + expect(page).to have_selector('[data-testid="deploy-keys-list"]') + expect(page).to have_link('New deploy key', href: new_admin_deploy_key_path) + end + end end diff --git a/spec/features/admin/admin_disables_two_factor_spec.rb b/spec/features/admin/admin_disables_two_factor_spec.rb index 1f34c4ed17c..f65e85b4cb6 100644 --- a/spec/features/admin/admin_disables_two_factor_spec.rb +++ b/spec/features/admin/admin_disables_two_factor_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe 'Admin disables 2FA for a user' do it 'successfully', :js do + stub_feature_flags(bootstrap_confirmation_modals: false) admin = create(:admin) sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb index 8315b8f44b0..8d4e7a7442c 100644 --- a/spec/features/admin/admin_groups_spec.rb +++ b/spec/features/admin/admin_groups_spec.rb @@ -252,6 +252,7 @@ RSpec.describe 'Admin Groups' do describe 'admin remove themself from a group', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/222342' do it 'removes admin from the group' do + stub_feature_flags(bootstrap_confirmation_modals: false) group.add_user(current_user, Gitlab::Access::DEVELOPER) visit group_group_members_path(group) diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index a501efd82ed..32e4d18227e 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -79,6 +79,7 @@ RSpec.describe 'Admin::Hooks' do let(:hook_url) { generate(:url) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) create(:system_hook, url: hook_url) end diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb index 08d81906d9f..65de1160cfd 100644 --- a/spec/features/admin/admin_labels_spec.rb +++ b/spec/features/admin/admin_labels_spec.rb @@ -14,6 +14,7 @@ RSpec.describe 'admin issues labels' do describe 'list' do before do + stub_feature_flags(bootstrap_confirmation_modals: false) visit admin_labels_path end diff --git a/spec/features/admin/admin_manage_applications_spec.rb b/spec/features/admin/admin_manage_applications_spec.rb index b6437fce540..4cf290293bd 100644 --- a/spec/features/admin/admin_manage_applications_spec.rb +++ b/spec/features/admin/admin_manage_applications_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe 'admin manage applications' do let_it_be(:new_application_path) { new_admin_application_path } let_it_be(:applications_path) { admin_applications_path } + let_it_be(:index_path) { admin_applications_path } before do admin = create(:admin) diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 8053be89ffc..7e2751daefa 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -24,40 +24,37 @@ RSpec.describe "Admin Runners" do visit admin_runners_path - expect(page).to have_text "Set up a shared runner manually" + expect(page).to have_text "Register an instance runner" expect(page).to have_text "Runners currently online: 1" end - it 'with an instance runner shows an instance badge and no project count' do + it 'with an instance runner shows an instance badge' do runner = create(:ci_runner, :instance) visit admin_runners_path within "[data-testid='runner-row-#{runner.id}']" do expect(page).to have_selector '.badge', text: 'shared' - expect(page).to have_text 'n/a' end end - it 'with a group runner shows a group badge and no project count' do + it 'with a group runner shows a group badge' do runner = create(:ci_runner, :group, groups: [group]) visit admin_runners_path within "[data-testid='runner-row-#{runner.id}']" do expect(page).to have_selector '.badge', text: 'group' - expect(page).to have_text 'n/a' end end - it 'with a project runner shows a project badge and project count' do + it 'with a project runner shows a project badge' do runner = create(:ci_runner, :project, projects: [project]) visit admin_runners_path within "[data-testid='runner-row-#{runner.id}']" do expect(page).to have_selector '.badge', text: 'specific' - expect(page).to have_text '1' end end @@ -69,6 +66,13 @@ RSpec.describe "Admin Runners" do visit admin_runners_path end + it 'runner types tabs have total counts and can be selected' do + expect(page).to have_link('All 2') + expect(page).to have_link('Instance 2') + expect(page).to have_link('Group 0') + expect(page).to have_link('Project 0') + end + it 'shows runners' do expect(page).to have_content("runner-foo") expect(page).to have_content("runner-bar") @@ -137,6 +141,19 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' end + + it 'shows correct runner when type is selected and search term is entered' do + create(:ci_runner, :instance, description: 'runner-connected', contacted_at: Time.now) + create(:ci_runner, :instance, description: 'runner-not-connected', contacted_at: nil) + + visit admin_runners_path + + # use the string "Not" to avoid using space and trigger an early selection + input_filtered_search_filter_is_only('Status', 'Not') + + expect(page).not_to have_content 'runner-connected' + expect(page).to have_content 'runner-not-connected' + end end describe 'filter by type' do @@ -145,13 +162,25 @@ RSpec.describe "Admin Runners" do create(:ci_runner, :group, description: 'runner-group', groups: [group]) end + it '"All" tab is selected by default' do + visit admin_runners_path + + page.within('[data-testid="runner-type-tabs"]') do + expect(page).to have_link('All', class: 'active') + end + end + it 'shows correct runner when type matches' do visit admin_runners_path expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-group' - input_filtered_search_filter_is_only('Type', 'project') + page.within('[data-testid="runner-type-tabs"]') do + click_on('Project') + + expect(page).to have_link('Project', class: 'active') + end expect(page).to have_content 'runner-project' expect(page).not_to have_content 'runner-group' @@ -160,7 +189,11 @@ RSpec.describe "Admin Runners" do it 'shows no runner when type does not match' do visit admin_runners_path - input_filtered_search_filter_is_only('Type', 'instance') + page.within('[data-testid="runner-type-tabs"]') do + click_on 'Instance' + + expect(page).to have_link('Instance', class: 'active') + end expect(page).not_to have_content 'runner-project' expect(page).not_to have_content 'runner-group' @@ -173,7 +206,9 @@ RSpec.describe "Admin Runners" do visit admin_runners_path - input_filtered_search_filter_is_only('Type', 'project') + page.within('[data-testid="runner-type-tabs"]') do + click_on 'Project' + end expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-2-project' @@ -185,6 +220,26 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-2-project' expect(page).not_to have_content 'runner-group' end + + it 'maintains the same filter when switching between runner types' do + create(:ci_runner, :project, description: 'runner-paused-project', active: false, projects: [project]) + + visit admin_runners_path + + input_filtered_search_filter_is_only('Status', 'Active') + + expect(page).to have_content 'runner-project' + expect(page).to have_content 'runner-group' + expect(page).not_to have_content 'runner-paused-project' + + page.within('[data-testid="runner-type-tabs"]') do + click_on 'Project' + end + + expect(page).to have_content 'runner-project' + expect(page).not_to have_content 'runner-group' + expect(page).not_to have_content 'runner-paused-project' + end end describe 'filter by tag' do @@ -267,29 +322,55 @@ RSpec.describe "Admin Runners" do end it 'has all necessary texts including no runner message' do - expect(page).to have_text "Set up a shared runner manually" + expect(page).to have_text "Register an instance runner" expect(page).to have_text "Runners currently online: 0" expect(page).to have_text 'No runners found' end end - describe 'runners registration token' do + describe 'runners registration' do let!(:token) { Gitlab::CurrentSettings.runners_registration_token } before do visit admin_runners_path + + click_on 'Register an instance runner' + 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 + page.within('[role="dialog"]') do + click_button('Close', match: :first) + end + + expect(page).not_to have_text "Install a runner" + end end it 'has a registration token' do click_on 'Click to reveal' - expect(page.find('[data-testid="registration-token"]')).to have_content(token) + expect(page.find('[data-testid="token-value"]')).to have_content(token) end describe 'reset registration token' do - let(:page_token) { find('[data-testid="registration-token"]').text } + let(:page_token) { find('[data-testid="token-value"]').text } before do - click_button 'Reset registration token' + click_on 'Reset registration token' page.accept_alert @@ -297,6 +378,8 @@ RSpec.describe "Admin Runners" do 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 diff --git a/spec/features/admin/admin_sees_project_statistics_spec.rb b/spec/features/admin/admin_sees_project_statistics_spec.rb index 3433cc01b8e..9d9217c4574 100644 --- a/spec/features/admin/admin_sees_project_statistics_spec.rb +++ b/spec/features/admin/admin_sees_project_statistics_spec.rb @@ -16,7 +16,7 @@ RSpec.describe "Admin > Admin sees project statistics" do let(:project) { create(:project, :repository) } it "shows project statistics" do - expect(page).to have_content("Storage: 0 Bytes (Repository: 0 Bytes / Wikis: 0 Bytes / Build Artifacts: 0 Bytes / LFS: 0 Bytes / Snippets: 0 Bytes / Packages: 0 Bytes / Uploads: 0 Bytes)") + expect(page).to have_content("Storage: 0 Bytes (Repository: 0 Bytes / Wikis: 0 Bytes / Build Artifacts: 0 Bytes / Pipeline Artifacts: 0 Bytes / LFS: 0 Bytes / Snippets: 0 Bytes / Packages: 0 Bytes / Uploads: 0 Bytes)") end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 1c50a7f891f..0a39baca259 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -491,22 +491,22 @@ RSpec.describe 'Admin updates settings' do group = create(:group) page.within('.as-performance-bar') do - check 'Allow non-administrators to access to the performance bar' + check 'Allow non-administrators access to the performance bar' fill_in 'Allow access to members of the following group', with: group.path click_on 'Save changes' end expect(page).to have_content "Application settings saved successfully" - expect(find_field('Allow non-administrators to access to the performance bar')).to be_checked + expect(find_field('Allow non-administrators access to the performance bar')).to be_checked expect(find_field('Allow access to members of the following group').value).to eq group.path page.within('.as-performance-bar') do - uncheck 'Allow non-administrators to access to the performance bar' + uncheck 'Allow non-administrators access to the performance bar' click_on 'Save changes' end expect(page).to have_content 'Application settings saved successfully' - expect(find_field('Allow non-administrators to access to the performance bar')).not_to be_checked + expect(find_field('Allow non-administrators access to the performance bar')).not_to be_checked expect(find_field('Allow access to members of the following group').value).to be_nil end diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb index ed8ea84fbf8..6643ebe82e6 100644 --- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb +++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb @@ -74,6 +74,7 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } it "allows revocation of an active impersonation token" do + stub_feature_flags(bootstrap_confirmation_modals: false) visit admin_user_impersonation_tokens_path(user_id: user.username) accept_confirm { click_on "Revoke" } diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb index 0e448446085..c13313609b5 100644 --- a/spec/features/admin/admin_uses_repository_checks_spec.rb +++ b/spec/features/admin/admin_uses_repository_checks_spec.rb @@ -8,6 +8,7 @@ RSpec.describe 'Admin uses repository checks', :request_store do let(:admin) { create(:admin) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') sign_in(admin) end diff --git a/spec/features/admin/clusters/eks_spec.rb b/spec/features/admin/clusters/eks_spec.rb index a1bac720349..bb2678de2ae 100644 --- a/spec/features/admin/clusters/eks_spec.rb +++ b/spec/features/admin/clusters/eks_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'Instance-level AWS EKS Cluster', :js do before do visit admin_clusters_path - click_link 'Integrate with a cluster certificate' + click_link 'Connect with a certificate' end context 'when user creates a cluster on AWS EKS' do diff --git a/spec/features/admin/users/user_spec.rb b/spec/features/admin/users/user_spec.rb index 624bfde7359..73477fb93dd 100644 --- a/spec/features/admin/users/user_spec.rb +++ b/spec/features/admin/users/user_spec.rb @@ -9,6 +9,7 @@ RSpec.describe 'Admin::Users::User' do let_it_be(:current_user) { create(:admin) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(current_user) gitlab_enable_admin_mode_sign_in(current_user) end diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb index 119b01ff552..fa943245fcb 100644 --- a/spec/features/admin/users/users_spec.rb +++ b/spec/features/admin/users/users_spec.rb @@ -9,6 +9,7 @@ RSpec.describe 'Admin::Users' do let_it_be(:current_user) { create(:admin) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(current_user) gitlab_enable_admin_mode_sign_in(current_user) end @@ -164,7 +165,7 @@ RSpec.describe 'Admin::Users' do visit admin_users_path - page.within('.filter-two-factor-enabled small') do + page.within('.filter-two-factor-enabled .gl-tab-counter-badge') do expect(page).to have_content('1') end end @@ -181,7 +182,7 @@ RSpec.describe 'Admin::Users' do it 'counts users who have not enabled 2FA' do visit admin_users_path - page.within('.filter-two-factor-disabled small') do + page.within('.filter-two-factor-disabled .gl-tab-counter-badge') do expect(page).to have_content('2') # Including admin end end @@ -200,7 +201,7 @@ RSpec.describe 'Admin::Users' do visit admin_users_path - page.within('.filter-blocked-pending-approval small') do + page.within('.filter-blocked-pending-approval .gl-tab-counter-badge') do expect(page).to have_content('2') end end diff --git a/spec/features/alert_management/alert_management_list_spec.rb b/spec/features/alert_management/alert_management_list_spec.rb index 1e710169c9c..2fbce27033e 100644 --- a/spec/features/alert_management/alert_management_list_spec.rb +++ b/spec/features/alert_management/alert_management_list_spec.rb @@ -55,28 +55,4 @@ RSpec.describe 'Alert Management index', :js do it_behaves_like 'alert page with title, filtered search, and table' end end - - describe 'managed_alerts_deprecation feature flag' do - subject { page } - - before do - stub_feature_flags(managed_alerts_deprecation: feature_flag_value) - sign_in(developer) - - visit project_alert_management_index_path(project) - wait_for_requests - end - - context 'feature flag on' do - let(:feature_flag_value) { true } - - it { is_expected.to have_pushed_frontend_feature_flags(managedAlertsDeprecation: true) } - end - - context 'feature flag off' do - let(:feature_flag_value) { false } - - it { is_expected.to have_pushed_frontend_feature_flags(managedAlertsDeprecation: false) } - end - end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 9a5b5bbfc34..2f21961d1fc 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -536,6 +536,7 @@ RSpec.describe 'Project issue boards', :js do let_it_be(:user_guest) { create(:user) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) project.add_guest(user_guest) sign_in(user_guest) visit project_board_path(project, board) diff --git a/spec/features/clusters/create_agent_spec.rb b/spec/features/clusters/create_agent_spec.rb new file mode 100644 index 00000000000..f40932c4750 --- /dev/null +++ b/spec/features/clusters/create_agent_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Cluster agent registration', :js do + let_it_be(:project) { create(:project, :custom_repo, files: { '.gitlab/agents/example-agent-1/config.yaml' => '' }) } + let_it_be(:current_user) { create(:user, maintainer_projects: [project]) } + + before do + allow(Gitlab::Kas).to receive(:enabled?).and_return(true) + allow(Gitlab::Kas).to receive(:internal_url).and_return('kas.example.internal') + + allow_next_instance_of(Gitlab::Kas::Client) do |client| + allow(client).to receive(:list_agent_config_files).and_return([ + double(agent_name: 'example-agent-1', path: '.gitlab/agents/example-agent-1/config.yaml'), + double(agent_name: 'example-agent-2', path: '.gitlab/agents/example-agent-2/config.yaml') + ]) + end + + allow(Devise).to receive(:friendly_token).and_return('example-agent-token') + + sign_in(current_user) + visit project_clusters_path(project) + end + + it 'allows the user to select an agent to install, and displays the resulting agent token' do + click_button('Actions') + expect(page).to have_content('Install new Agent') + + click_button('Select an Agent') + click_button('example-agent-2') + click_button('Register Agent') + + expect(page).to have_content('The token value will not be shown again after you close this window.') + expect(page).to have_content('example-agent-token') + expect(page).to have_content('docker run --pull=always --rm') + + within find('.modal-footer') do + click_button('Close') + end + + expect(page).to have_link('example-agent-2') + end +end diff --git a/spec/features/contextual_sidebar_spec.rb b/spec/features/contextual_sidebar_spec.rb index 39881a28b11..29c7e0ddd21 100644 --- a/spec/features/contextual_sidebar_spec.rb +++ b/spec/features/contextual_sidebar_spec.rb @@ -3,35 +3,110 @@ require 'spec_helper' RSpec.describe 'Contextual sidebar', :js do - let_it_be(:project) { create(:project) } + context 'when context is a project' do + let_it_be(:project) { create(:project) } - let(:user) { project.owner } + let(:user) { project.owner } - before do - sign_in(user) + before do + sign_in(user) + end - visit project_path(project) - end + context 'when analyzing the menu' do + before do + visit project_path(project) + end + + it 'shows flyout navs when collapsed or expanded apart from on the active item when expanded', :aggregate_failures do + expect(page).not_to have_selector('.js-sidebar-collapsed') + + find('.rspec-link-pipelines').hover + + expect(page).to have_selector('.is-showing-fly-out') + + find('.rspec-project-link').hover + + expect(page).not_to have_selector('.is-showing-fly-out') + + find('.rspec-toggle-sidebar').click + + find('.rspec-link-pipelines').hover + + expect(page).to have_selector('.is-showing-fly-out') - it 'shows flyout navs when collapsed or expanded apart from on the active item when expanded', :aggregate_failures do - expect(page).not_to have_selector('.js-sidebar-collapsed') + find('.rspec-project-link').hover + + expect(page).to have_selector('.is-showing-fly-out') + end + end + + context 'with invite_members_in_side_nav experiment', :experiment do + it 'allows opening of modal for the candidate experience' do + stub_experiments(invite_members_in_side_nav: :candidate) + expect(experiment(:invite_members_in_side_nav)).to track(:assignment) + .with_context(group: project.group) + .on_next_instance + + visit project_path(project) + + page.within '[data-test-id="side-nav-invite-members"' do + find('[data-test-id="invite-members-button"').click + end + + expect(page).to have_content("You're inviting members to the") + end + + it 'does not have invite members link in side nav for the control experience' do + stub_experiments(invite_members_in_side_nav: :control) + expect(experiment(:invite_members_in_side_nav)).to track(:assignment) + .with_context(group: project.group) + .on_next_instance + + visit project_path(project) + + expect(page).not_to have_css('[data-test-id="side-nav-invite-members"') + end + end + end - find('.rspec-link-pipelines').hover + context 'when context is a group' do + let_it_be(:user) { create(:user) } + let_it_be(:group) do + create(:group).tap do |g| + g.add_owner(user) + end + end - expect(page).to have_selector('.is-showing-fly-out') + before do + sign_in(user) + end - find('.rspec-project-link').hover + context 'with invite_members_in_side_nav experiment', :experiment do + it 'allows opening of modal for the candidate experience' do + stub_experiments(invite_members_in_side_nav: :candidate) + expect(experiment(:invite_members_in_side_nav)).to track(:assignment) + .with_context(group: group) + .on_next_instance - expect(page).not_to have_selector('.is-showing-fly-out') + visit group_path(group) - find('.rspec-toggle-sidebar').click + page.within '[data-test-id="side-nav-invite-members"' do + find('[data-test-id="invite-members-button"').click + end - find('.rspec-link-pipelines').hover + expect(page).to have_content("You're inviting members to the") + end - expect(page).to have_selector('.is-showing-fly-out') + it 'does not have invite members link in side nav for the control experience' do + stub_experiments(invite_members_in_side_nav: :control) + expect(experiment(:invite_members_in_side_nav)).to track(:assignment) + .with_context(group: group) + .on_next_instance - find('.rspec-project-link').hover + visit group_path(group) - expect(page).to have_selector('.is-showing-fly-out') + expect(page).not_to have_css('[data-test-id="side-nav-invite-members"') + end + end end end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 34a55118cb3..69361f66a71 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -6,6 +6,7 @@ RSpec.describe 'Value Stream Analytics', :js do let_it_be(:user) { create(:user) } let_it_be(:guest) { create(:user) } let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' } + let_it_be(:stage_filter_bar) { '[data-testid="vsa-filter-bar"]' } let_it_be(:stage_table_event_selector) { '[data-testid="vsa-stage-event"]' } let_it_be(:stage_table_event_title_selector) { '[data-testid="vsa-stage-event-title"]' } let_it_be(:stage_table_pagination_selector) { '[data-testid="vsa-stage-pagination"]' } @@ -27,9 +28,16 @@ RSpec.describe 'Value Stream Analytics', :js do def set_daterange(from_date, to_date) page.find(".js-daterange-picker-from input").set(from_date) page.find(".js-daterange-picker-to input").set(to_date) + + # simulate a blur event + page.find(".js-daterange-picker-to input").send_keys(:tab) wait_for_all_requests end + before do + stub_feature_flags(use_vsa_aggregated_tables: false) + end + context 'as an allowed user' do context 'when project is new' do before do @@ -97,7 +105,7 @@ RSpec.describe 'Value Stream Analytics', :js do end end - it 'shows data on each stage', :sidekiq_might_not_need_inline do + it 'shows data on each stage', :sidekiq_might_not_need_inline, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338332' do expect_issue_to_be_present click_stage('Plan') @@ -133,7 +141,7 @@ RSpec.describe 'Value Stream Analytics', :js do expect(metrics_values).to eq(['-'] * 4) end - it 'can sort records' do + it 'can sort records', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338332' do # NOTE: checking that the string changes should suffice # depending on the order the tests are run we might run into problems with hard coded strings original_first_title = first_stage_title @@ -158,6 +166,18 @@ RSpec.describe 'Value Stream Analytics', :js do expect(page).not_to have_text(original_first_title, exact: true) end + it 'can navigate directly to a value stream stream stage with filters applied' do + visit project_cycle_analytics_path(project, created_before: '2019-12-31', created_after: '2019-11-01', stage_id: 'code', milestone_title: milestone.title) + wait_for_requests + + expect(page).to have_selector('.gl-path-active-item-indigo', text: 'Code') + expect(page.find(".js-daterange-picker-from input").value).to eq("2019-11-01") + expect(page.find(".js-daterange-picker-to input").value).to eq("2019-12-31") + + filter_bar = page.find(stage_filter_bar) + expect(filter_bar.find(".gl-filtered-search-token-data-content").text).to eq("%#{milestone.title}") + end + def stage_time_column stage_table.find(stage_table_duration_column_header_selector).ancestor("th") end diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index 27419479479..82288a6c1a6 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -80,7 +80,7 @@ RSpec.describe 'Dashboard Projects' do visit dashboard_projects_path expect(page).to have_content(project.name) - expect(find('.nav-links li:nth-child(1) .badge-pill')).to have_content(1) + expect(find('.gl-tabs-nav li:nth-child(1) .badge-pill')).to have_content(1) end it 'shows personal projects on personal projects tab', :js do @@ -128,8 +128,8 @@ RSpec.describe 'Dashboard Projects' do expect(page).not_to have_content(project.name) expect(page).to have_content(project2.name) - expect(find('.nav-links li:nth-child(1) .badge-pill')).to have_content(1) - expect(find('.nav-links li:nth-child(2) .badge-pill')).to have_content(1) + expect(find('.gl-tabs-nav li:nth-child(1) .badge-pill')).to have_content(1) + expect(find('.gl-tabs-nav li:nth-child(2) .badge-pill')).to have_content(1) end it 'does not show tabs to filter by all projects or personal' do @@ -204,7 +204,7 @@ RSpec.describe 'Dashboard Projects' do visit dashboard_projects_path expect(page).to have_selector('[data-testid="project_topic_list"]') - expect(page).to have_link('topic1', href: explore_projects_path(topic: 'topic1')) + expect(page).to have_link('topic1', href: topic_explore_projects_path(topic_name: 'topic1')) end end diff --git a/spec/features/explore/topics_spec.rb b/spec/features/explore/topics_spec.rb new file mode 100644 index 00000000000..9d2e76bc3a1 --- /dev/null +++ b/spec/features/explore/topics_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Explore Topics' do + context 'when no topics exist' do + it 'renders empty message', :aggregate_failures do + visit topics_explore_projects_path + + expect(current_path).to eq topics_explore_projects_path + expect(page).to have_content('There are no topics to show.') + end + end + + context 'when topics exist' do + let!(:topic) { create(:topic, name: 'topic1') } + + it 'renders topic list' do + visit topics_explore_projects_path + + expect(current_path).to eq topics_explore_projects_path + expect(page).to have_content('topic1') + end + end +end diff --git a/spec/features/graphql_known_operations_spec.rb b/spec/features/graphql_known_operations_spec.rb new file mode 100644 index 00000000000..ef406f12902 --- /dev/null +++ b/spec/features/graphql_known_operations_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# We need to distinguish between known and unknown GraphQL operations. This spec +# tests that we set up Gitlab::Graphql::KnownOperations.default which requires +# integration of FE queries, webpack plugin, and BE. +RSpec.describe 'Graphql known operations', :js do + around do |example| + # Let's make sure we aren't receiving or leaving behind any side-effects + # https://gitlab.com/gitlab-org/gitlab/-/jobs/1743294100 + ::Gitlab::Graphql::KnownOperations.instance_variable_set(:@default, nil) + ::Gitlab::Webpack::GraphqlKnownOperations.clear_memoization! + + example.run + + ::Gitlab::Graphql::KnownOperations.instance_variable_set(:@default, nil) + ::Gitlab::Webpack::GraphqlKnownOperations.clear_memoization! + end + + it 'collects known Graphql operations from the code', :aggregate_failures do + # Check that we include some arbitrary operation name we expect + known_operations = Gitlab::Graphql::KnownOperations.default.operations.map(&:name) + + expect(known_operations).to include("searchProjects") + expect(known_operations.length).to be > 20 + expect(known_operations).to all( match(%r{^[a-z]+}i) ) + end +end diff --git a/spec/features/groups/clusters/eks_spec.rb b/spec/features/groups/clusters/eks_spec.rb index c361c502cbb..fe62efbd3bf 100644 --- a/spec/features/groups/clusters/eks_spec.rb +++ b/spec/features/groups/clusters/eks_spec.rb @@ -19,7 +19,7 @@ RSpec.describe 'Group AWS EKS Cluster', :js do before do visit group_clusters_path(group) - click_link 'Integrate with a cluster certificate' + click_link 'Connect with a certificate' 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 2a7ededa39b..1788167c94c 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 'Integrate with a cluster certificate' + click_link 'Connect with a certificate' click_link 'Connect existing cluster' end @@ -129,7 +129,7 @@ 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('Integrate with a cluster certificate') + expect(page).to have_link('Connect with a certificate') end end end diff --git a/spec/features/groups/dependency_proxy_spec.rb b/spec/features/groups/dependency_proxy_spec.rb index d6b0bdc8ea4..623fb065bfc 100644 --- a/spec/features/groups/dependency_proxy_spec.rb +++ b/spec/features/groups/dependency_proxy_spec.rb @@ -56,9 +56,14 @@ RSpec.describe 'Group Dependency Proxy' do visit settings_path wait_for_requests - click_button 'Enable Proxy' + proxy_toggle = find('[data-testid="dependency-proxy-setting-toggle"]') + proxy_toggle_button = proxy_toggle.find('button') - expect(page).to have_button 'Enable Proxy', class: '!is-checked' + expect(proxy_toggle).to have_css("button.is-checked") + + proxy_toggle_button.click + + expect(proxy_toggle).not_to have_css("button.is-checked") visit path diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 489beb70ab3..4e59ab40d04 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -83,6 +83,18 @@ RSpec.describe 'Group issues page' do end end + it 'truncates issue counts if over the threshold', :clean_gitlab_redis_cache do + allow(Rails.cache).to receive(:read).and_call_original + allow(Rails.cache).to receive(:read).with( + ['group', group.id, 'issues'], + { expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN } + ).and_return({ opened: 1050, closed: 500, all: 1550 }) + + visit issues_group_path(group) + + expect(page).to have_text('Open 1.1k Closed 500 All 1.6k') + end + context 'when project is archived' do before do ::Projects::UpdateService.new(project, user_in_group, archived: true).execute @@ -94,41 +106,6 @@ RSpec.describe 'Group issues page' do expect(page).not_to have_content issue.title[0..80] end end - - context 'when cached issues state count is enabled', :clean_gitlab_redis_cache do - before do - stub_feature_flags(cached_issues_state_count: true) - end - - it 'truncates issue counts if over the threshold' do - allow(Rails.cache).to receive(:read).and_call_original - allow(Rails.cache).to receive(:read).with( - ['group', group.id, 'issues'], - { expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN } - ).and_return({ opened: 1050, closed: 500, all: 1550 }) - - visit issues_group_path(group) - - expect(page).to have_text('Open 1.1k Closed 500 All 1.6k') - end - end - - context 'when cached issues state count is disabled', :clean_gitlab_redis_cache do - before do - stub_feature_flags(cached_issues_state_count: false) - end - - it 'does not truncate counts if they are over the threshold' do - allow_next_instance_of(IssuesFinder) do |finder| - allow(finder).to receive(:count_by_state).and_return(true) - .and_return({ opened: 1050, closed: 500, all: 1550 }) - end - - visit issues_group_path(group) - - expect(page).to have_text('Open 1,050 Closed 500 All 1,550') - end - end end context 'projects with issues disabled' do diff --git a/spec/features/groups/labels/subscription_spec.rb b/spec/features/groups/labels/subscription_spec.rb index dedded777ac..231c4b33bee 100644 --- a/spec/features/groups/labels/subscription_spec.rb +++ b/spec/features/groups/labels/subscription_spec.rb @@ -71,7 +71,7 @@ RSpec.describe 'Labels subscription' do end it 'does not show subscribed tab' do - page.within('.nav-tabs') do + page.within('.gl-tabs-nav') do expect(page).not_to have_link 'Subscribed' end end @@ -86,7 +86,7 @@ RSpec.describe 'Labels subscription' do end def click_subscribed_tab - page.within('.nav-tabs') do + page.within('.gl-tabs-nav') do click_link 'Subscribed' end end diff --git a/spec/features/groups/members/leave_group_spec.rb b/spec/features/groups/members/leave_group_spec.rb index b73313745e9..e6bf1ffc2f7 100644 --- a/spec/features/groups/members/leave_group_spec.rb +++ b/spec/features/groups/members/leave_group_spec.rb @@ -10,6 +10,7 @@ RSpec.describe 'Groups > Members > Leave group' do let(:group) { create(:group) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) end diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index 0a159056569..22409e9e7f6 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -15,6 +15,7 @@ RSpec.describe 'Group navbar' do insert_package_nav(_('Kubernetes')) stub_feature_flags(group_iterations: false) + stub_feature_flags(customer_relations: false) stub_config(dependency_proxy: { enabled: false }) stub_config(registry: { enabled: false }) stub_group_wikis(false) @@ -40,6 +41,22 @@ RSpec.describe 'Group navbar' do it_behaves_like 'verified navigation bar' end + context 'when customer_relations feature flag is enabled' do + before do + stub_feature_flags(customer_relations: true) + + if Gitlab.ee? + insert_customer_relations_nav(_('Analytics')) + else + insert_customer_relations_nav(_('Packages & Registries')) + end + + visit group_path(group) + end + + it_behaves_like 'verified navigation bar' + end + context 'when dependency proxy is available' do before do stub_config(dependency_proxy: { enabled: true }) diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb index 0dfc7180187..3c2ade6b274 100644 --- a/spec/features/groups/packages_spec.rb +++ b/spec/features/groups/packages_spec.rb @@ -28,10 +28,6 @@ RSpec.describe 'Group Packages' do context 'when feature is available', :js do before do - # we are simply setting the featrure flag to false because the new UI has nothing to test yet - # when the refactor is complete or almost complete we will turn on the feature tests - # see https://gitlab.com/gitlab-org/gitlab/-/issues/330846 for status of this work - stub_feature_flags(package_list_apollo: false) visit_group_packages end diff --git a/spec/features/groups/settings/manage_applications_spec.rb b/spec/features/groups/settings/manage_applications_spec.rb index 5f84f61678d..277471cb304 100644 --- a/spec/features/groups/settings/manage_applications_spec.rb +++ b/spec/features/groups/settings/manage_applications_spec.rb @@ -6,6 +6,7 @@ RSpec.describe 'User manages applications' do let_it_be(:group) { create(:group) } let_it_be(:user) { create(:user) } let_it_be(:new_application_path) { group_settings_applications_path(group) } + let_it_be(:index_path) { group_settings_applications_path(group) } before do group.add_owner(user) diff --git a/spec/features/incidents/user_creates_new_incident_spec.rb b/spec/features/incidents/user_creates_new_incident_spec.rb index 99a137b5852..685f6ab791a 100644 --- a/spec/features/incidents/user_creates_new_incident_spec.rb +++ b/spec/features/incidents/user_creates_new_incident_spec.rb @@ -4,52 +4,49 @@ require 'spec_helper' RSpec.describe 'Incident Management index', :js do let_it_be(:project) { create(:project) } - let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user) } let_it_be(:guest) { create(:user) } let_it_be(:incident) { create(:incident, project: project) } before_all do - project.add_developer(developer) + project.add_reporter(reporter) project.add_guest(guest) end - shared_examples 'create incident form' do - it 'shows the create new issue button' do - expect(page).to have_selector('.create-incident-button') - end + before do + sign_in(user) - it 'when clicked shows the create issue page with the Incident type pre-selected' do - find('.create-incident-button').click - wait_for_all_requests + visit project_incidents_path(project) + wait_for_all_requests + end - expect(page).to have_selector('.dropdown-menu-toggle') - expect(page).to have_selector('.js-issuable-type-filter-dropdown-wrap') + describe 'incident list is visited' do + context 'by reporter' do + let(:user) { reporter } - page.within('.js-issuable-type-filter-dropdown-wrap') do - expect(page).to have_content('Incident') + it 'shows the create new incident button' do + expect(page).to have_selector('.create-incident-button') end - end - end - context 'when a developer displays the incident list' do - before do - sign_in(developer) + it 'when clicked shows the create issue page with the Incident type pre-selected' do + find('.create-incident-button').click + wait_for_all_requests - visit project_incidents_path(project) - wait_for_all_requests - end + expect(page).to have_selector('.dropdown-menu-toggle') + expect(page).to have_selector('.js-issuable-type-filter-dropdown-wrap') - it_behaves_like 'create incident form' + page.within('.js-issuable-type-filter-dropdown-wrap') do + expect(page).to have_content('Incident') + end + end + end end - context 'when a guest displays the incident list' do - before do - sign_in(guest) + context 'by guest' do + let(:user) { guest } - visit project_incidents_path(project) - wait_for_all_requests + it 'does not show new incident button' do + expect(page).not_to have_selector('.create-incident-button') end - - it_behaves_like 'create incident form' end end diff --git a/spec/features/incidents/user_views_incident_spec.rb b/spec/features/incidents/user_views_incident_spec.rb index 244b66f7a9a..fe54f7708c9 100644 --- a/spec/features/incidents/user_views_incident_spec.rb +++ b/spec/features/incidents/user_views_incident_spec.rb @@ -22,12 +22,30 @@ RSpec.describe "User views incident" do it_behaves_like 'page meta description', ' Description header Lorem ipsum dolor sit amet' - it 'shows the merge request and incident actions', :js, :aggregate_failures do - click_button 'Incident actions' + describe 'user actions' do + it 'shows the merge request and incident actions', :js, :aggregate_failures do + click_button 'Incident actions' - expect(page).to have_link('New incident', href: new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident', description: "Related to \##{incident.iid}.\n\n" } })) - expect(page).to have_button('Create merge request') - expect(page).to have_button('Close incident') + expect(page).to have_link('New incident', href: new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident', description: "Related to \##{incident.iid}.\n\n" } })) + expect(page).to have_button('Create merge request') + expect(page).to have_button('Close incident') + end + + context 'when user is a guest' do + before do + project.add_guest(user) + + login_as(user) + + visit(project_issues_incident_path(project, incident)) + end + + it 'does not show the incident action', :js, :aggregate_failures do + click_button 'Incident actions' + + expect(page).not_to have_link('New incident') + end + end end context 'when the project is archived' do diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb index 87fb8955dcc..f9ab780d2d6 100644 --- a/spec/features/invites_spec.rb +++ b/spec/features/invites_spec.rb @@ -103,6 +103,20 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do expect(page).to have_content('You are already a member of this group.') end end + + context 'when email case doesnt match', :js do + let(:invite_email) { 'User@example.com' } + let(:user) { create(:user, email: 'user@example.com') } + + before do + sign_in(user) + visit invite_path(group_invite.raw_invite_token) + end + + it 'accepts invite' do + expect(page).to have_content('You have been granted Developer access to group Owned.') + end + end end context 'when declining the invitation from invitation reminder email' do diff --git a/spec/features/issuables/markdown_references/internal_references_spec.rb b/spec/features/issuables/markdown_references/internal_references_spec.rb index 07d4271eed7..2dcabb38b8f 100644 --- a/spec/features/issuables/markdown_references/internal_references_spec.rb +++ b/spec/features/issuables/markdown_references/internal_references_spec.rb @@ -53,9 +53,7 @@ RSpec.describe "Internal references", :js do end it "doesn't show any references", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/257832' do - page.within(".issue-details") do - expect(page).not_to have_content("#merge-requests .merge-requests-title") - end + expect(page).not_to have_text 'Related merge requests' end end @@ -65,12 +63,9 @@ RSpec.describe "Internal references", :js do end it "shows references", :sidekiq_might_not_need_inline do - page.within("#merge-requests .merge-requests-title") do - expect(page).to have_content("Related merge requests") - expect(page).to have_css(".mr-count-badge") - end + expect(page).to have_text 'Related merge requests 1' - page.within("#merge-requests ul") do + page.within('.related-items-list') do expect(page).to have_content(private_project_merge_request.title) expect(page).to have_css(".ic-issue-open-m") end @@ -122,9 +117,7 @@ RSpec.describe "Internal references", :js do end it "doesn't show any references", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/257832' do - page.within(".merge-request-details") do - expect(page).not_to have_content("#merge-requests .merge-requests-title") - end + expect(page).not_to have_text 'Related merge requests' end end diff --git a/spec/features/issue_rebalancing_spec.rb b/spec/features/issue_rebalancing_spec.rb new file mode 100644 index 00000000000..978768270ec --- /dev/null +++ b/spec/features/issue_rebalancing_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Issue rebalancing' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:user) { create(:user) } + + let(:alert_message_regex) { /Issues are being rebalanced at the moment/ } + + before_all do + create(:issue, project: project) + + group.add_developer(user) + end + + context 'when issue rebalancing is in progress' do + before do + sign_in(user) + + stub_feature_flags(block_issue_repositioning: true) + end + + it 'shows an alert in project boards' do + board = create(:board, project: project) + + visit project_board_path(project, board) + + expect(page).to have_selector('.gl-alert-info', text: alert_message_regex, count: 1) + end + + it 'shows an alert in group boards' do + board = create(:board, group: group) + + visit group_board_path(group, board) + + expect(page).to have_selector('.gl-alert-info', text: alert_message_regex, count: 1) + end + + it 'shows an alert in project issues list with manual sort' do + visit project_issues_path(project, sort: 'relative_position') + + expect(page).to have_selector('.gl-alert-info', text: alert_message_regex, count: 1) + end + + it 'shows an alert in group issues list with manual sort' do + visit issues_group_path(group, sort: 'relative_position') + + expect(page).to have_selector('.gl-alert-info', text: alert_message_regex, count: 1) + end + + it 'does not show an alert in project issues list with other sorts' do + visit project_issues_path(project, sort: 'created_date') + + expect(page).not_to have_selector('.gl-alert-info', text: alert_message_regex) + end + + it 'does not show an alert in group issues list with other sorts' do + visit issues_group_path(group, sort: 'created_date') + + expect(page).not_to have_selector('.gl-alert-info', text: alert_message_regex) + end + end +end diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 4bad67acc87..b26f65316c5 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -4,25 +4,29 @@ require 'spec_helper' RSpec.describe 'New/edit issue', :js do include ActionView::Helpers::JavaScriptHelper - include FormHelper let_it_be(:project) { create(:project) } - let_it_be(:user) { create(:user)} - let_it_be(:user2) { create(:user)} + let_it_be(:user) { create(:user) } + let_it_be(:user2) { create(:user) } let_it_be(:milestone) { create(:milestone, project: project) } let_it_be(:label) { create(:label, project: project) } let_it_be(:label2) { create(:label, project: project) } let_it_be(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) } - before do - stub_licensed_features(multiple_issue_assignees: false, issue_weights: false) + let(:current_user) { user } + before_all do project.add_maintainer(user) project.add_maintainer(user2) - sign_in(user) end - context 'new issue' do + before do + stub_licensed_features(multiple_issue_assignees: false, issue_weights: false) + + sign_in(current_user) + end + + describe 'new issue' do before do visit new_project_issue_path(project) end @@ -235,29 +239,61 @@ RSpec.describe 'New/edit issue', :js do end describe 'displays issue type options in the dropdown' do + shared_examples 'type option is visible' do |label:, identifier:| + it "shows #{identifier} option", :aggregate_failures do + page.within('[data-testid="issue-type-select-dropdown"]') do + expect(page).to have_selector(%([data-testid="issue-type-#{identifier}-icon"])) + expect(page).to have_content(label) + end + end + end + + shared_examples 'type option is missing' do |label:, identifier:| + it "does not show #{identifier} option", :aggregate_failures do + page.within('[data-testid="issue-type-select-dropdown"]') do + expect(page).not_to have_selector(%([data-testid="issue-type-#{identifier}-icon"])) + expect(page).not_to have_content(label) + end + end + end + before do page.within('.issue-form') do click_button 'Issue' end end - it 'correctly displays the Issue type option with an icon', :aggregate_failures do - page.within('[data-testid="issue-type-select-dropdown"]') do - expect(page).to have_selector('[data-testid="issue-type-issue-icon"]') - expect(page).to have_content('Issue') + context 'when user is guest' do + let_it_be(:guest) { create(:user) } + + let(:current_user) { guest } + + before_all do + project.add_guest(guest) end + + it_behaves_like 'type option is visible', label: 'Issue', identifier: :issue + it_behaves_like 'type option is missing', label: 'Incident', identifier: :incident end - it 'correctly displays the Incident type option with an icon', :aggregate_failures do - page.within('[data-testid="issue-type-select-dropdown"]') do - expect(page).to have_selector('[data-testid="issue-type-incident-icon"]') - expect(page).to have_content('Incident') + context 'when user is reporter' do + let_it_be(:reporter) { create(:user) } + + let(:current_user) { reporter } + + before_all do + project.add_reporter(reporter) end + + it_behaves_like 'type option is visible', label: 'Issue', identifier: :issue + it_behaves_like 'type option is visible', label: 'Incident', identifier: :incident end end describe 'milestone' do - let!(:milestone) { create(:milestone, title: '"><img src=x onerror=alert(document.domain)>', project: project) } + let!(:milestone) do + create(:milestone, title: '"><img src=x onerror=alert(document.domain)>', project: project) + end it 'escapes milestone' do click_button 'Milestone' @@ -274,7 +310,7 @@ RSpec.describe 'New/edit issue', :js do end end - context 'edit issue' do + describe 'edit issue' do before do visit edit_project_issue_path(project, issue) end @@ -329,7 +365,7 @@ RSpec.describe 'New/edit issue', :js do end end - context 'inline edit' do + describe 'inline edit' do before do visit project_issue_path(project, issue) end diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb index 531c3634b5e..b37c8e9d1cf 100644 --- a/spec/features/issues/issue_detail_spec.rb +++ b/spec/features/issues/issue_detail_spec.rb @@ -3,8 +3,9 @@ require 'spec_helper' RSpec.describe 'Issue Detail', :js do + let_it_be_with_refind(:project) { create(:project, :public) } + let(:user) { create(:user) } - let(:project) { create(:project, :public) } let(:issue) { create(:issue, project: project, author: user) } let(:incident) { create(:incident, project: project, author: user) } @@ -90,7 +91,13 @@ RSpec.describe 'Issue Detail', :js do end describe 'user updates `issue_type` via the issue type dropdown' do - context 'when an issue `issue_type` is edited by a signed in user' do + let_it_be(:reporter) { create(:user) } + + before_all do + project.add_reporter(reporter) + end + + describe 'when an issue `issue_type` is edited' do before do sign_in(user) @@ -98,18 +105,33 @@ RSpec.describe 'Issue Detail', :js do wait_for_requests end - it 'routes the user to the incident details page when the `issue_type` is set to incident' do - open_issue_edit_form + context 'by non-member author' do + it 'cannot see Incident option' do + open_issue_edit_form + + page.within('[data-testid="issuable-form"]') do + expect(page).to have_content('Issue') + expect(page).not_to have_content('Incident') + end + end + end + + context 'by reporter' do + let(:user) { reporter } - page.within('[data-testid="issuable-form"]') do - update_type_select('Issue', 'Incident') + it 'routes the user to the incident details page when the `issue_type` is set to incident' do + open_issue_edit_form - expect(page).to have_current_path(project_issues_incident_path(project, issue)) + page.within('[data-testid="issuable-form"]') do + update_type_select('Issue', 'Incident') + + expect(page).to have_current_path(project_issues_incident_path(project, issue)) + end end end end - context 'when an incident `issue_type` is edited by a signed in user' do + describe 'when an incident `issue_type` is edited' do before do sign_in(user) @@ -117,13 +139,29 @@ RSpec.describe 'Issue Detail', :js do wait_for_requests end - it 'routes the user to the issue details page when the `issue_type` is set to issue' do - open_issue_edit_form + context 'by non-member author' do + it 'routes the user to the issue details page when the `issue_type` is set to issue' do + open_issue_edit_form + + page.within('[data-testid="issuable-form"]') do + update_type_select('Incident', 'Issue') + + expect(page).to have_current_path(project_issue_path(project, incident)) + end + end + end + + context 'by reporter' do + let(:user) { reporter } + + it 'routes the user to the issue details page when the `issue_type` is set to issue' do + open_issue_edit_form - page.within('[data-testid="issuable-form"]') do - update_type_select('Incident', 'Issue') + page.within('[data-testid="issuable-form"]') do + update_type_select('Incident', 'Issue') - expect(page).to have_current_path(project_issue_path(project, incident)) + expect(page).to have_current_path(project_issue_path(project, incident)) + 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 f46aa5c21b6..37e324e6ded 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -171,7 +171,7 @@ RSpec.describe "User creates issue" do end context 'form create handles issue creation by default' do - let(:project) { create(:project) } + let_it_be(:project) { create(:project) } before do visit new_project_issue_path(project) @@ -187,30 +187,22 @@ RSpec.describe "User creates issue" do end context 'form create handles incident creation' do - let(:project) { create(:project) } + let_it_be(:project) { create(:project) } before do visit new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }) end - it 'pre-fills the issue type dropdown with incident type' do - expect(find('.js-issuable-type-filter-dropdown-wrap .dropdown-toggle-text')).to have_content('Incident') - end - - it 'hides the epic select' do - expect(page).not_to have_selector('.epic-dropdown-container') + it 'does not pre-fill the issue type dropdown with incident type' do + expect(find('.js-issuable-type-filter-dropdown-wrap .dropdown-toggle-text')).not_to have_content('Incident') end it 'shows the milestone select' do expect(page).to have_selector('.qa-issuable-milestone-dropdown') # rubocop:disable QA/SelectorUsage end - it 'hides the weight input' do - expect(page).not_to have_selector('.qa-issuable-weight-input') # rubocop:disable QA/SelectorUsage - end - - it 'shows the incident help text' do - expect(page).to have_text('A modified issue to guide the resolution of incidents.') + it 'hides the incident help text' do + expect(page).not_to have_text('A modified issue to guide the resolution of incidents.') end end @@ -242,6 +234,44 @@ RSpec.describe "User creates issue" do end end + context 'when signed in as reporter', :js do + let_it_be(:project) { create(:project) } + + before_all do + project.add_reporter(user) + end + + before do + sign_in(user) + end + + context 'form create handles incident creation' do + before do + visit new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }) + end + + it 'pre-fills the issue type dropdown with incident type' do + expect(find('.js-issuable-type-filter-dropdown-wrap .dropdown-toggle-text')).to have_content('Incident') + end + + it 'hides the epic select' do + expect(page).not_to have_selector('.epic-dropdown-container') + end + + it 'shows the milestone select' do + expect(page).to have_selector('.qa-issuable-milestone-dropdown') # rubocop:disable QA/SelectorUsage + end + + it 'hides the weight input' do + expect(page).not_to have_selector('.qa-issuable-weight-input') # rubocop:disable QA/SelectorUsage + end + + it 'shows the incident help text' do + expect(page).to have_text('A modified issue to guide the resolution of incidents.') + end + end + end + context "when signed in as user with special characters in their name" do let(:user_special) { create(:user, name: "Jon O'Shea") } diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index 63c36a20adc..76cec2502e3 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -146,8 +146,7 @@ RSpec.describe "Issues > User edits issue", :js do fill_in 'Comment', with: '/label ~syzygy' click_button 'Comment' - - wait_for_requests + expect(page).to have_text('added syzygy label just now') page.within '.block.labels' do # Remove `verisimilitude` label @@ -155,8 +154,6 @@ RSpec.describe "Issues > User edits issue", :js do click_button end - wait_for_requests - expect(page).to have_text('syzygy') expect(page).not_to have_text('verisimilitude') end diff --git a/spec/features/issues/user_toggles_subscription_spec.rb b/spec/features/issues/user_toggles_subscription_spec.rb index 9809bb34d26..541bbc8a8e7 100644 --- a/spec/features/issues/user_toggles_subscription_spec.rb +++ b/spec/features/issues/user_toggles_subscription_spec.rb @@ -45,7 +45,7 @@ RSpec.describe "User toggles subscription", :js do it 'is disabled' do expect(page).to have_content('Disabled by project owner') - expect(page).to have_button('Notifications', class: 'is-disabled') + expect(page).to have_selector('[data-testid="subscription-toggle"]', class: 'is-disabled') end end end diff --git a/spec/features/issues/user_uses_quick_actions_spec.rb b/spec/features/issues/user_uses_quick_actions_spec.rb index d88b816b186..c6d743ed38f 100644 --- a/spec/features/issues/user_uses_quick_actions_spec.rb +++ b/spec/features/issues/user_uses_quick_actions_spec.rb @@ -44,5 +44,6 @@ RSpec.describe 'Issues > User uses quick actions', :js do it_behaves_like 'move quick action' it_behaves_like 'zoom quick actions' it_behaves_like 'clone quick action' + it_behaves_like 'promote_to_incident quick action' end end diff --git a/spec/features/jira_connect/subscriptions_spec.rb b/spec/features/jira_connect/subscriptions_spec.rb index 9be6b7c67ee..e1589ba997e 100644 --- a/spec/features/jira_connect/subscriptions_spec.rb +++ b/spec/features/jira_connect/subscriptions_spec.rb @@ -40,8 +40,8 @@ RSpec.describe 'Subscriptions Content Security Policy' do visit jira_connect_subscriptions_path(jwt: jwt) is_expected.to include("frame-ancestors 'self' https://*.atlassian.net") - is_expected.to include("script-src 'self' https://some-cdn.test https://connect-cdn.atl-paas.net https://unpkg.com/jquery@3.3.1/") - is_expected.to include("style-src 'self' https://some-cdn.test 'unsafe-inline' https://unpkg.com/@atlaskit/") + 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 end end diff --git a/spec/features/merge_request/user_approves_spec.rb b/spec/features/merge_request/user_approves_spec.rb index f401dd598f3..4f7bcb58551 100644 --- a/spec/features/merge_request/user_approves_spec.rb +++ b/spec/features/merge_request/user_approves_spec.rb @@ -17,7 +17,7 @@ RSpec.describe 'Merge request > User approves', :js do it 'approves merge request' do click_approval_button('Approve') - expect(page).to have_content('Merge request approved') + expect(page).to have_content('Approved by you') verify_approvals_count_on_index! diff --git a/spec/features/merge_request/user_assigns_themselves_spec.rb b/spec/features/merge_request/user_assigns_themselves_spec.rb index 04d401683bf..fc925781a3b 100644 --- a/spec/features/merge_request/user_assigns_themselves_spec.rb +++ b/spec/features/merge_request/user_assigns_themselves_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Merge request > User assigns themselves' do visit project_merge_request_path(project, merge_request) end - it 'updates related issues', :js do + it 'updates related issues', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/343006' do click_link 'Assign yourself to these issues' expect(page).to have_content '2 issues have been assigned to you' diff --git a/spec/features/merge_request/user_comments_on_diff_spec.rb b/spec/features/merge_request/user_comments_on_diff_spec.rb index 54c3fe738d2..f9b554c5ed2 100644 --- a/spec/features/merge_request/user_comments_on_diff_spec.rb +++ b/spec/features/merge_request/user_comments_on_diff_spec.rb @@ -14,6 +14,7 @@ RSpec.describe 'User comments on a diff', :js do let(:user) { create(:user) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) project.add_maintainer(user) sign_in(user) diff --git a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb index 1d3d76d3486..06795344c5c 100644 --- a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb +++ b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb @@ -26,33 +26,27 @@ RSpec.describe 'Merge request < User customizes merge commit message', :js do ].join("\n\n") end - let(:message_with_description) do - [ - "Merge branch 'feature' into 'master'", - merge_request.title, - merge_request.description, - "See merge request #{merge_request.to_reference(full: true)}" - ].join("\n\n") - end - before do project.add_maintainer(user) sign_in(user) visit project_merge_request_path(project, merge_request) end - it 'toggles commit message between message with description and without description' do + it 'has commit message without description' do expect(page).not_to have_selector('#merge-message-edit') first('.js-mr-widget-commits-count').click expect(textbox).to be_visible expect(textbox.value).to eq(default_message) + end - check('Include merge request description') - - expect(textbox.value).to eq(message_with_description) - - uncheck('Include merge request description') + context 'when target project has merge commit template set' do + let(:project) { create(:project, :public, :repository, merge_commit_template: '%{title}') } - expect(textbox.value).to eq(default_message) + it 'uses merge commit template' do + expect(page).not_to have_selector('#merge-message-edit') + first('.js-mr-widget-commits-count').click + expect(textbox).to be_visible + expect(textbox.value).to eq(merge_request.title) + end end end diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb index af5ba14e310..9057b96bff0 100644 --- a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do click_button "Merge when pipeline succeeds" expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds" - expect(page).to have_content "The source branch will not be deleted" + expect(page).to have_content "Does not delete the source branch" expect(page).to have_selector ".js-cancel-auto-merge" visit project_merge_request_path(project, merge_request) # Needed to refresh the page expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i @@ -126,7 +126,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do it 'allows to delete source branch' do click_button "Delete source branch" - expect(page).to have_content "The source branch will be deleted" + expect(page).to have_content "Deletes the source branch" end context 'when pipeline succeeds' do diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb index c339a7d9976..79e46e69157 100644 --- a/spec/features/merge_request/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb @@ -18,6 +18,7 @@ RSpec.describe 'Merge request > User posts diff notes', :js do project.add_developer(user) sign_in(user) + stub_feature_flags(bootstrap_confirmation_modals: false) end context 'when hovering over a parallel view diff file' do @@ -237,8 +238,10 @@ RSpec.describe 'Merge request > User posts diff notes', :js do def should_allow_dismissing_a_comment(line_holder, diff_side = nil) write_comment_on_line(line_holder, diff_side) - accept_confirm do - find('.js-close-discussion-note-form').click + find('.js-close-discussion-note-form').click + + page.within('.modal') do + click_button 'OK' end assert_comment_dismissal(line_holder) diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb index 83d9388914b..0416474218f 100644 --- a/spec/features/merge_request/user_posts_notes_spec.rb +++ b/spec/features/merge_request/user_posts_notes_spec.rb @@ -18,8 +18,10 @@ RSpec.describe 'Merge request > User posts notes', :js do end before do + stub_feature_flags(bootstrap_confirmation_modals: false) project.add_maintainer(user) sign_in(user) + visit project_merge_request_path(project, merge_request) end diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb index 90cdc28d1bd..64cd5aa2bb1 100644 --- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb +++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb @@ -79,6 +79,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do %w(parallel).each do |view| context "#{view} view" do before do + stub_feature_flags(bootstrap_confirmation_modals: false) visit diffs_project_merge_request_path(project, merge_request, view: view) wait_for_requests diff --git a/spec/features/merge_request/user_sees_deployment_widget_spec.rb b/spec/features/merge_request/user_sees_deployment_widget_spec.rb index 873cc0a89c6..345404cc28f 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -110,6 +110,7 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do let(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') } before do + stub_feature_flags(bootstrap_confirmation_modals: false) build.success! deployment.update!(on_stop: manual.name) visit project_merge_request_path(project, merge_request) 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 f74b097ab3e..0117cf01e53 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -426,7 +426,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do it 'user cannot remove source branch', :sidekiq_might_not_need_inline do expect(page).not_to have_field('remove-source-branch-input') - expect(page).to have_content('The source branch will be deleted') + expect(page).to have_content('Deletes the source branch') end end diff --git a/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb b/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb index 3893a9cdf28..2191849edd9 100644 --- a/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb +++ b/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb @@ -16,7 +16,8 @@ RSpec.describe 'Merge request > User sees suggest pipeline', :js do end it 'shows the suggest pipeline widget and then allows dismissal correctly' do - expect(page).to have_content('Are you adding technical debt or code vulnerabilities?') + content = 'GitLab CI/CD can automatically build, test, and deploy your application' + expect(page).to have_content(content) page.within '.mr-pipeline-suggest' do find('[data-testid="close"]').click @@ -24,17 +25,16 @@ RSpec.describe 'Merge request > User sees suggest pipeline', :js do wait_for_requests - expect(page).not_to have_content('Are you adding technical debt or code vulnerabilities?') + expect(page).not_to have_content(content) # Reload so we know the user callout was registered visit page.current_url - expect(page).not_to have_content('Are you adding technical debt or code vulnerabilities?') + expect(page).not_to have_content(content) end - it 'runs tour from start to finish ensuring all nudges are executed' do - # nudge 1 - expect(page).to have_content('Are you adding technical debt or code vulnerabilities?') + it 'takes the user to the pipeline editor with a pre-filled CI config file form' do + expect(page).to have_content('GitLab CI/CD can automatically build, test, and deploy your application') page.within '.mr-pipeline-suggest' do find('[data-testid="ok"]').click @@ -42,30 +42,14 @@ RSpec.describe 'Merge request > User sees suggest pipeline', :js do wait_for_requests - # nudge 2 - expect(page).to have_content('Choose Code Quality to add a pipeline that tests the quality of your code.') + # Drawer is open + expect(page).to have_content('This template creates a simple test pipeline. To use it:') - find('.js-gitlab-ci-yml-selector').click + # Editor shows template + expect(page).to have_content('This file is a template, and might need editing before it works on your project.') - wait_for_requests - - within '.gitlab-ci-yml-selector' do - find('.dropdown-input-field').set('Jekyll') - find('.dropdown-content li', text: 'Jekyll').click - end - - wait_for_requests - - expect(page).not_to have_content('Choose Code Quality to add a pipeline that tests the quality of your code.') - # nudge 3 - expect(page).to have_content('The template is ready!') - - find('#commit-changes').click - - wait_for_requests - - # nudge 4 - expect(page).to have_content("That's it, well done!") + # Commit form is shown + expect(page).to have_button('Commit changes') end context 'when feature setting is disabled' do diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb index 3402bda5a41..0ea14bc00a5 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] + :facebook, :cas3, :auth0, :authentiq, :salesforce, :dingtalk] around do |example| with_omniauth_full_host { example.run } diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index 9a261c6d9c8..7d935298f38 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -6,6 +6,7 @@ RSpec.describe 'Profile account page', :js do let(:user) { create(:user) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) end @@ -80,6 +81,7 @@ RSpec.describe 'Profile account page', :js do describe 'when I reset incoming email token' do before do allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true) + stub_feature_flags(bootstrap_confirmation_modals: false) visit profile_personal_access_tokens_path end diff --git a/spec/features/profiles/active_sessions_spec.rb b/spec/features/profiles/active_sessions_spec.rb index fd64704b7c8..a515c7b1c1f 100644 --- a/spec/features/profiles/active_sessions_spec.rb +++ b/spec/features/profiles/active_sessions_spec.rb @@ -11,6 +11,10 @@ RSpec.describe 'Profile > Active Sessions', :clean_gitlab_redis_shared_state do let(:admin) { create(:admin) } + before do + stub_feature_flags(bootstrap_confirmation_modals: false) + end + it 'user sees their active sessions' do travel_to(Time.zone.parse('2018-03-12 09:06')) do Capybara::Session.new(:session1) diff --git a/spec/features/profiles/emails_spec.rb b/spec/features/profiles/emails_spec.rb index 6b6f628e2d5..8f05de60be9 100644 --- a/spec/features/profiles/emails_spec.rb +++ b/spec/features/profiles/emails_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe 'Profile > Emails' do let(:user) { create(:user) } + let(:other_user) { create(:user) } before do sign_in(user) @@ -23,15 +24,25 @@ RSpec.describe 'Profile > Emails' do expect(page).to have_content('Resend confirmation email') end - it 'does not add a duplicate email' do - fill_in('Email', with: user.email) + it 'does not add an email that is the primary email of another user' do + fill_in('Email', with: other_user.email) click_button('Add email address') - email = user.emails.find_by(email: user.email) + email = user.emails.find_by(email: other_user.email) expect(email).to be_nil expect(page).to have_content('Email has already been taken') end + it 'adds an email that is the primary email of the same user' do + fill_in('Email', with: user.email) + click_button('Add email address') + + email = user.emails.find_by(email: user.email) + expect(email).to be_present + expect(page).to have_content("#{user.email} Verified") + expect(page).not_to have_content("#{user.email} Unverified") + end + it 'does not add an invalid email' do fill_in('Email', with: 'test.@example.com') click_button('Add email address') diff --git a/spec/features/profiles/oauth_applications_spec.rb b/spec/features/profiles/oauth_applications_spec.rb index 2735f601307..6827dff5434 100644 --- a/spec/features/profiles/oauth_applications_spec.rb +++ b/spec/features/profiles/oauth_applications_spec.rb @@ -7,6 +7,7 @@ RSpec.describe 'Profile > Applications' do let(:application) { create(:oauth_application, owner: user) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 8f44299b18f..74505633cae 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -34,6 +34,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do end before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) end diff --git a/spec/features/profiles/two_factor_auths_spec.rb b/spec/features/profiles/two_factor_auths_spec.rb index 3f5789e119a..a9256a73d7b 100644 --- a/spec/features/profiles/two_factor_auths_spec.rb +++ b/spec/features/profiles/two_factor_auths_spec.rb @@ -45,6 +45,19 @@ RSpec.describe 'Two factor auths' do expect(page).to have_content('Status: Enabled') end end + + context 'when invalid pin is provided' do + let_it_be(:user) { create(:omniauth_user) } + + it 'renders a error alert with a link to the troubleshooting section' do + visit profile_two_factor_auth_path + + fill_in 'pin_code', with: '123' + click_button 'Register with two-factor app' + + expect(page).to have_link('Try the troubleshooting steps here.', href: help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting')) + end + end end context 'when user has two-factor authentication enabled' do @@ -57,7 +70,9 @@ RSpec.describe 'Two factor auths' do click_button 'Disable two-factor authentication' - page.accept_alert + page.within('[role="dialog"]') do + click_button 'Disable' + end expect(page).to have_content('You must provide a valid current password') @@ -65,7 +80,9 @@ RSpec.describe 'Two factor auths' do click_button 'Disable two-factor authentication' - page.accept_alert + page.within('[role="dialog"]') do + click_button 'Disable' + end expect(page).to have_content('Two-factor authentication has been disabled successfully!') expect(page).to have_content('Enable two-factor authentication') @@ -95,7 +112,9 @@ RSpec.describe 'Two factor auths' do click_button 'Disable two-factor authentication' - page.accept_alert + page.within('[role="dialog"]') do + click_button 'Disable' + end expect(page).to have_content('Two-factor authentication has been disabled successfully!') expect(page).to have_content('Enable two-factor authentication') diff --git a/spec/features/profiles/user_manages_applications_spec.rb b/spec/features/profiles/user_manages_applications_spec.rb index c76ef2613fd..ea7a6b4b6ba 100644 --- a/spec/features/profiles/user_manages_applications_spec.rb +++ b/spec/features/profiles/user_manages_applications_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe 'User manages applications' do let_it_be(:user) { create(:user) } let_it_be(:new_application_path) { applications_profile_path } + let_it_be(:index_path) { oauth_applications_path } before do sign_in(user) diff --git a/spec/features/profiles/user_manages_emails_spec.rb b/spec/features/profiles/user_manages_emails_spec.rb index 373c4f565f2..b037d5048aa 100644 --- a/spec/features/profiles/user_manages_emails_spec.rb +++ b/spec/features/profiles/user_manages_emails_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe 'User manages emails' do let(:user) { create(:user) } + let(:other_user) { create(:user) } before do sign_in(user) @@ -11,7 +12,7 @@ RSpec.describe 'User manages emails' do visit(profile_emails_path) end - it "shows user's emails" do + it "shows user's emails", :aggregate_failures do expect(page).to have_content(user.email) user.emails.each do |email| @@ -19,7 +20,7 @@ RSpec.describe 'User manages emails' do end end - it 'adds an email' do + it 'adds an email', :aggregate_failures do fill_in('email_email', with: 'my@email.com') click_button('Add') @@ -34,21 +35,21 @@ RSpec.describe 'User manages emails' do end end - it 'does not add a duplicate email' do - fill_in('email_email', with: user.email) + it 'does not add an email that is the primary email of another user', :aggregate_failures do + fill_in('email_email', with: other_user.email) click_button('Add') - email = user.emails.find_by(email: user.email) + email = user.emails.find_by(email: other_user.email) expect(email).to be_nil - expect(page).to have_content(user.email) + expect(page).to have_content('Email has already been taken') user.emails.each do |email| expect(page).to have_content(email.email) end end - it 'removes an email' do + it 'removes an email', :aggregate_failures do fill_in('email_email', with: 'my@email.com') click_button('Add') diff --git a/spec/features/profiles/user_visits_profile_spec.rb b/spec/features/profiles/user_visits_profile_spec.rb index 475fda5e7a1..273d52996d3 100644 --- a/spec/features/profiles/user_visits_profile_spec.rb +++ b/spec/features/profiles/user_visits_profile_spec.rb @@ -21,6 +21,14 @@ RSpec.describe 'User visits their profile' do expect(page).to have_content "This information will appear on your profile" end + it 'shows user readme' do + create(:project, :repository, :public, path: user.username, namespace: user.namespace) + + visit(user_path(user)) + + expect(find('.file-content')).to have_content('testme') + end + context 'when user has groups' do let(:group) do create :group do |group| diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb index 5139c724d82..cc59fea173b 100644 --- a/spec/features/project_variables_spec.rb +++ b/spec/features/project_variables_spec.rb @@ -21,7 +21,7 @@ RSpec.describe 'Project variables', :js do click_button('Add variable') page.within('#add-ci-variable') do - find('[data-qa-selector="ci_variable_key_field"] input').set('akey') # rubocop:disable QA/SelectorUsage + fill_in 'Key', with: 'akey' find('#ci-variable-value').set('akey_value') find('[data-testid="environment-scope"]').click find('[data-testid="ci-environment-search"]').set('review/*') diff --git a/spec/features/projects/branches/user_deletes_branch_spec.rb b/spec/features/projects/branches/user_deletes_branch_spec.rb index 3b8f49accc5..8fc5c3d2e1b 100644 --- a/spec/features/projects/branches/user_deletes_branch_spec.rb +++ b/spec/features/projects/branches/user_deletes_branch_spec.rb @@ -35,6 +35,7 @@ RSpec.describe "User deletes branch", :js do context 'when the feature flag :delete_branch_confirmation_modals is disabled' do before do + stub_feature_flags(bootstrap_confirmation_modals: false) stub_feature_flags(delete_branch_confirmation_modals: false) end diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index 0a79719f14a..2725c6a91be 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -179,6 +179,7 @@ RSpec.describe 'Branches' do context 'when the delete_branch_confirmation_modals feature flag is disabled' do it 'removes branch after confirmation', :js do stub_feature_flags(delete_branch_confirmation_modals: false) + stub_feature_flags(bootstrap_confirmation_modals: false) visit project_branches_filtered_path(project, state: 'all') diff --git a/spec/features/projects/cluster_agents_spec.rb b/spec/features/projects/cluster_agents_spec.rb new file mode 100644 index 00000000000..3ef710169f0 --- /dev/null +++ b/spec/features/projects/cluster_agents_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ClusterAgents', :js do + let_it_be(:token) { create(:cluster_agent_token, description: 'feature test token')} + + let(:agent) { token.agent } + let(:project) { agent.project } + let(:user) { project.creator } + + before do + gitlab_sign_in(user) + end + + context 'when user does not have any agents and visits the index page' do + let(:empty_project) { create(:project) } + + before do + empty_project.add_maintainer(user) + visit project_clusters_path(empty_project) + end + + it 'displays empty state', :aggregate_failures do + expect(page).to have_content('Install new Agent') + expect(page).to have_selector('.empty-state') + end + end + + context 'when user has an agent' do + context 'when visiting the index page' do + before do + visit project_clusters_path(project) + end + + it 'displays a table with agent', :aggregate_failures do + expect(page).to have_content(agent.name) + expect(page).to have_selector('[data-testid="cluster-agent-list-table"] tbody tr', count: 1) + end + end + + context 'when visiting the show page' do + before do + visit project_cluster_agent_path(project, agent.name) + end + + it 'displays agent and token information', :aggregate_failures do + expect(page).to have_content(agent.name) + expect(page).to have_content(token.description) + end + end + end +end diff --git a/spec/features/projects/clusters/eks_spec.rb b/spec/features/projects/clusters/eks_spec.rb index 9f3f331cfab..09c10c0b3a9 100644 --- a/spec/features/projects/clusters/eks_spec.rb +++ b/spec/features/projects/clusters/eks_spec.rb @@ -19,7 +19,8 @@ RSpec.describe 'AWS EKS Cluster', :js do before do visit project_clusters_path(project) - click_link 'Integrate with a cluster certificate' + click_link 'Certificate based' + click_link 'Connect with a certificate' 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 21e587288f5..e1659cd2fbf 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -33,7 +33,8 @@ RSpec.describe 'Gcp Cluster', :js do before do visit project_clusters_path(project) - click_link 'Integrate with a cluster certificate' + click_link 'Certificate based' + click_link 'Connect with a certificate' click_link 'Create new cluster' click_link 'Google GKE' end @@ -143,8 +144,9 @@ RSpec.describe 'Gcp Cluster', :js do before do visit project_clusters_path(project) - click_link 'Connect cluster with certificate' - click_link 'Connect existing cluster' + click_link 'Certificate based' + click_button(class: 'dropdown-toggle-split') + click_link 'Connect with certificate' end it 'user sees the "Environment scope" field' do @@ -158,11 +160,12 @@ RSpec.describe 'Gcp Cluster', :js do click_button 'Remove integration and resources' fill_in 'confirm_cluster_name_input', with: cluster.name click_button 'Remove integration' + click_link 'Certificate based' end 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('Integrate with a cluster certificate') + expect(page).to have_link('Connect with a certificate') end end end @@ -171,6 +174,7 @@ RSpec.describe 'Gcp Cluster', :js do context 'when user has not dismissed GCP signup offer' do before do visit project_clusters_path(project) + click_link 'Certificate based' end it 'user sees offer on cluster index page' do @@ -178,7 +182,7 @@ RSpec.describe 'Gcp Cluster', :js do end it 'user sees offer on cluster create page' do - click_link 'Integrate with a cluster certificate' + click_link 'Connect with a certificate' expect(page).to have_css('.gcp-signup-offer') end @@ -187,6 +191,7 @@ RSpec.describe 'Gcp Cluster', :js do context 'when user has dismissed GCP signup offer' do before do visit project_clusters_path(project) + click_link 'Certificate based' end it 'user does not see offer after dismissing' do @@ -195,19 +200,18 @@ RSpec.describe 'Gcp Cluster', :js do find('.gcp-signup-offer .js-close').click wait_for_requests - click_link 'Integrate with a cluster certificate' + click_link 'Connect with a certificate' expect(page).not_to have_css('.gcp-signup-offer') end end context 'when third party offers are disabled', :clean_gitlab_redis_shared_state do - let(:admin) { create(:admin) } + let(:user) { create(:admin) } before do stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) + gitlab_enable_admin_mode_sign_in(user) visit general_admin_application_settings_path end diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb index 5b60edbcf87..d3f709bfb53 100644 --- a/spec/features/projects/clusters/user_spec.rb +++ b/spec/features/projects/clusters/user_spec.rb @@ -25,7 +25,8 @@ RSpec.describe 'User Cluster', :js do before do visit project_clusters_path(project) - click_link 'Integrate with a cluster certificate' + click_link 'Certificate based' + click_link 'Connect with a certificate' click_link 'Connect existing cluster' end @@ -112,11 +113,12 @@ RSpec.describe 'User Cluster', :js do click_button 'Remove integration and resources' fill_in 'confirm_cluster_name_input', with: cluster.name click_button 'Remove integration' + click_link 'Certificate based' end 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('Integrate with a cluster certificate') + 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 6b03301aa74..a49fa4c9e31 100644 --- a/spec/features/projects/clusters_spec.rb +++ b/spec/features/projects/clusters_spec.rb @@ -16,10 +16,11 @@ RSpec.describe 'Clusters', :js do context 'when user does not have a cluster and visits cluster index page' do before do visit project_clusters_path(project) + click_link 'Certificate based' end it 'sees empty state' do - expect(page).to have_link('Integrate with a cluster certificate') + expect(page).to have_link('Connect with a certificate') expect(page).to have_selector('.empty-state') end end @@ -33,16 +34,17 @@ RSpec.describe 'Clusters', :js do before do create(:cluster, :provided_by_user, name: 'default-cluster', environment_scope: '*', projects: [project]) visit project_clusters_path(project) + click_link 'Certificate based' + click_button(class: 'dropdown-toggle-split') end it 'user sees an add cluster button' do - expect(page).to have_selector('.js-add-cluster:not(.readonly)') + expect(page).to have_content('Connect with certificate') end context 'when user filled form with environment scope' do before do - click_link 'Connect cluster with certificate' - click_link 'Connect existing cluster' + click_link 'Connect with certificate' fill_in 'cluster_name', with: 'staging-cluster' fill_in 'cluster_environment_scope', with: 'staging/*' click_button 'Add Kubernetes cluster' @@ -70,8 +72,7 @@ RSpec.describe 'Clusters', :js do context 'when user updates duplicated environment scope' do before do - click_link 'Connect cluster with certificate' - click_link 'Connect existing cluster' + click_link 'Connect with certificate' fill_in 'cluster_name', with: 'staging-cluster' fill_in 'cluster_environment_scope', with: '*' fill_in 'cluster_platform_kubernetes_attributes_api_url', with: 'https://0.0.0.0' @@ -108,15 +109,12 @@ RSpec.describe 'Clusters', :js do create(:cluster, :provided_by_gcp, name: 'default-cluster', environment_scope: '*', projects: [project]) visit project_clusters_path(project) - end - - it 'user sees a add cluster button' do - expect(page).to have_selector('.js-add-cluster:not(.readonly)') + click_link 'Certificate based' end context 'when user filled form with environment scope' do before do - click_link 'Connect cluster with certificate' + click_button(class: 'dropdown-toggle-split') click_link 'Create new cluster' click_link 'Google GKE' @@ -161,7 +159,7 @@ RSpec.describe 'Clusters', :js do context 'when user updates duplicated environment scope' do before do - click_link 'Connect cluster with certificate' + click_button(class: 'dropdown-toggle-split') click_link 'Create new cluster' click_link 'Google GKE' @@ -192,6 +190,7 @@ RSpec.describe 'Clusters', :js do before do visit project_clusters_path(project) + click_link 'Certificate based' end it 'user sees a table with one cluster' do @@ -214,7 +213,8 @@ RSpec.describe 'Clusters', :js do before do visit project_clusters_path(project) - click_link 'Integrate with a cluster certificate' + click_link 'Certificate based' + click_link 'Connect with a certificate' click_link 'Create new cluster' end diff --git a/spec/features/projects/commit/comments/user_deletes_comments_spec.rb b/spec/features/projects/commit/comments/user_deletes_comments_spec.rb index 431cbb4ffbb..67d3276fc14 100644 --- a/spec/features/projects/commit/comments/user_deletes_comments_spec.rb +++ b/spec/features/projects/commit/comments/user_deletes_comments_spec.rb @@ -11,6 +11,7 @@ RSpec.describe "User deletes comments on a commit", :js do let(:user) { create(:user) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) project.add_developer(user) diff --git a/spec/features/projects/commit/user_comments_on_commit_spec.rb b/spec/features/projects/commit/user_comments_on_commit_spec.rb index 6997c2d8338..b0be6edb245 100644 --- a/spec/features/projects/commit/user_comments_on_commit_spec.rb +++ b/spec/features/projects/commit/user_comments_on_commit_spec.rb @@ -93,6 +93,8 @@ RSpec.describe "User comments on commit", :js do context "when deleting comment" do before do + stub_feature_flags(bootstrap_confirmation_modals: false) + visit(project_commit_path(project, sample_commit.id)) add_note(comment_text) diff --git a/spec/features/projects/confluence/user_views_confluence_page_spec.rb b/spec/features/projects/confluence/user_views_confluence_page_spec.rb index ece2f82f5c6..49e7839f16c 100644 --- a/spec/features/projects/confluence/user_views_confluence_page_spec.rb +++ b/spec/features/projects/confluence/user_views_confluence_page_spec.rb @@ -16,9 +16,12 @@ RSpec.describe 'User views the Confluence page' do visit project_wikis_confluence_path(project) + expect(page).to have_css('.nav-sidebar li.active', text: 'Confluence', match: :first) + element = page.find('.row.empty-state') expect(element).to have_link('Go to Confluence', href: service.confluence_url) + expect(element).to have_link('Confluence epic', href: 'https://gitlab.com/groups/gitlab-org/-/epics/3629') end it 'does not show the page when the Confluence integration disabled' do diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 5320f68b525..bcbf2f46f79 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -23,10 +23,6 @@ RSpec.describe 'Environment' do let!(:action) { } let!(:cluster) { } - before do - visit_environment(environment) - end - context 'with auto-stop' do let!(:environment) { create(:environment, :will_auto_stop, name: 'staging', project: project) } @@ -52,12 +48,20 @@ RSpec.describe 'Environment' do end context 'without deployments' do + before do + visit_environment(environment) + end + it 'does not show deployments' do expect(page).to have_content('You don\'t have any deployments right now.') end end context 'with deployments' do + before do + visit_environment(environment) + end + context 'when there is no related deployable' do let(:deployment) do create(:deployment, :success, environment: environment, deployable: nil) @@ -108,6 +112,26 @@ RSpec.describe 'Environment' do end end + context 'with many deployments' do + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build) { create(:ci_build, pipeline: pipeline) } + + let!(:second) { create(:deployment, environment: environment, deployable: build, status: :success, finished_at: Time.current) } + let!(:first) { create(:deployment, environment: environment, deployable: build, status: :running) } + let!(:last) { create(:deployment, environment: environment, deployable: build, status: :success, finished_at: 2.days.ago) } + let!(:third) { create(:deployment, environment: environment, deployable: build, status: :canceled, finished_at: 1.day.ago) } + + before do + visit_environment(environment) + end + + it 'shows all of them in ordered way' do + ids = find_all('[data-testid="deployment-id"]').map { |e| e.text } + expected_ordered_ids = [first, second, third, last].map { |d| "##{d.iid}" } + expect(ids).to eq(expected_ordered_ids) + end + end + context 'with related deployable present' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } @@ -116,6 +140,10 @@ RSpec.describe 'Environment' do create(:deployment, :success, environment: environment, deployable: build) end + before do + visit_environment(environment) + end + it 'does show build name' do expect(page).to have_link("#{build.name} (##{build.id})") end diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 34e2ca7c8a7..3b83c25b629 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -8,6 +8,7 @@ RSpec.describe 'Environments page', :js do let(:role) { :developer } before do + stub_feature_flags(new_environments_table: false) project.add_role(user, role) sign_in(user) end @@ -142,6 +143,8 @@ RSpec.describe 'Environments page', :js do create(:environment, project: project, state: :available) end + stub_feature_flags(bootstrap_confirmation_modals: false) + context 'when there are no deployments' do before do visit_environments(project) diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 00e85a215b8..3afd1937652 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -31,7 +31,7 @@ RSpec.describe 'Import/Export - project import integration test', :js do it 'user imports an exported project successfully', :sidekiq_might_not_need_inline do visit new_project_path - click_import_project + click_link 'Import project' click_link 'GitLab export' fill_in :name, with: 'Test Project Name', visible: true @@ -50,7 +50,7 @@ RSpec.describe 'Import/Export - project import integration test', :js do visit new_project_path - click_import_project + click_link 'Import project' click_link 'GitLab export' fill_in :name, with: project.name, visible: true attach_file('file', file) @@ -61,8 +61,4 @@ RSpec.describe 'Import/Export - project import integration test', :js do end end end - - def click_import_project - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage - end end diff --git a/spec/features/projects/infrastructure_registry_spec.rb b/spec/features/projects/infrastructure_registry_spec.rb index ee35e02b5e8..27d0866bc69 100644 --- a/spec/features/projects/infrastructure_registry_spec.rb +++ b/spec/features/projects/infrastructure_registry_spec.rb @@ -43,7 +43,7 @@ RSpec.describe 'Infrastructure Registry' do expect(page).to have_current_path(project_infrastructure_registry_path(terraform_module.project, terraform_module)) - expect(page).to have_css('.packages-app h1[data-testid="title"]', text: terraform_module.name) + expect(page).to have_css('.packages-app h2[data-testid="title"]', text: terraform_module.name) expect(page).to have_content('Provision instructions') expect(page).to have_content('Registry setup') diff --git a/spec/features/projects/integrations/user_uses_inherited_settings_spec.rb b/spec/features/projects/integrations/user_uses_inherited_settings_spec.rb index f46cade9d5f..d2c4418f0d6 100644 --- a/spec/features/projects/integrations/user_uses_inherited_settings_spec.rb +++ b/spec/features/projects/integrations/user_uses_inherited_settings_spec.rb @@ -84,7 +84,7 @@ RSpec.describe 'User uses inherited settings', :js do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } let_it_be(:parent_settings) { { url: 'http://group.com', password: 'group' } } - let_it_be(:parent_integration) { create(:jira_integration, group: group, project: nil, **parent_settings) } + let_it_be(:parent_integration) { create(:jira_integration, :group, group: group, **parent_settings) } it_behaves_like 'inherited settings' end diff --git a/spec/features/projects/jobs/user_browses_job_spec.rb b/spec/features/projects/jobs/user_browses_job_spec.rb index 060b7ffbfc9..12e88bbf6a5 100644 --- a/spec/features/projects/jobs/user_browses_job_spec.rb +++ b/spec/features/projects/jobs/user_browses_job_spec.rb @@ -12,6 +12,7 @@ RSpec.describe 'User browses a job', :js do before do project.add_maintainer(user) project.enable_ci + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) @@ -36,8 +37,18 @@ RSpec.describe 'User browses a job', :js do expect(page).to have_content('Job has been erased') end - context 'with a failed job' do - let!(:build) { create(:ci_build, :failed, :trace_artifact, pipeline: pipeline) } + context 'with unarchived trace artifact' do + let!(:build) { create(:ci_build, :success, :unarchived_trace_artifact, :coverage, pipeline: pipeline) } + + it 'shows no trace message', :js do + wait_for_requests + + expect(page).to have_content('This job does not have a trace.') + end + end + + context 'with a failed job and live trace' do + let!(:build) { create(:ci_build, :failed, :trace_live, pipeline: pipeline) } it 'displays the failure reason' do wait_for_all_requests @@ -46,6 +57,18 @@ RSpec.describe 'User browses a job', :js do ".build-job > a[title='test - failed - (unknown failure)']") end end + + context 'with unarchived trace artifact' do + let!(:artifact) { create(:ci_job_artifact, :unarchived_trace_artifact, job: build) } + + it 'displays the failure reason from the live trace' do + wait_for_all_requests + within('.builds-container') do + expect(page).to have_selector( + ".build-job > a[title='test - failed - (unknown failure)']") + end + end + end end context 'when a failed job has been retried' do diff --git a/spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb b/spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb new file mode 100644 index 00000000000..e8a14694d88 --- /dev/null +++ b/spec/features/projects/jobs/user_triggers_manual_job_with_variables_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User triggers manual job with variables', :js do + let(:user) { create(:user) } + let(:user_access_level) { :developer } + let(:project) { create(:project, :repository, namespace: user.namespace) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let!(:build) { create(:ci_build, :manual, pipeline: pipeline) } + + before do + project.add_maintainer(user) + project.enable_ci + + sign_in(user) + + visit(project_job_path(project, build)) + end + + it 'passes values correctly' do + page.within(find("[data-testid='ci-variable-row']")) do + find("[data-testid='ci-variable-key']").set('key_name') + find("[data-testid='ci-variable-value']").set('key_value') + end + + find("[data-testid='trigger-manual-job-btn']").click + + wait_for_requests + + expect(build.job_variables.as_json).to contain_exactly( + hash_including('key' => 'key_name', 'value' => 'key_value')) + end +end diff --git a/spec/features/projects/members/member_leaves_project_spec.rb b/spec/features/projects/members/member_leaves_project_spec.rb index c4bd0b81dc0..4881a7bdf1a 100644 --- a/spec/features/projects/members/member_leaves_project_spec.rb +++ b/spec/features/projects/members/member_leaves_project_spec.rb @@ -9,6 +9,7 @@ RSpec.describe 'Projects > Members > Member leaves project' do before do project.add_developer(user) sign_in(user) + stub_feature_flags(bootstrap_confirmation_modals: false) end it 'user leaves project' do diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 113ba692497..dcaef5f4ef0 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -11,6 +11,7 @@ RSpec.describe 'Projects > Members > User requests access', :js do before do sign_in(user) visit project_path(project) + stub_feature_flags(bootstrap_confirmation_modals: false) end it 'request access feature is disabled' do diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index dacbaa826a0..4dedd5689de 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'New project', :js do ) visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect(page).to have_content 'Other visibility settings have been disabled by the administrator.' end @@ -34,7 +34,7 @@ RSpec.describe 'New project', :js do ) visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect(page).to have_content 'Visibility settings have been disabled by the administrator.' end @@ -49,14 +49,14 @@ RSpec.describe 'New project', :js do it 'shows "New project" page', :js do visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect(page).to have_content('Project name') expect(page).to have_content('Project URL') expect(page).to have_content('Project slug') click_link('New project') - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Import project' expect(page).to have_link('GitHub') expect(page).to have_link('Bitbucket') @@ -69,7 +69,7 @@ RSpec.describe 'New project', :js do before do visit new_project_path - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Import project' end it 'has Manifest file' do @@ -83,7 +83,7 @@ RSpec.describe 'New project', :js do stub_application_setting(default_project_visibility: level) visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' page.within('#blank-project-pane') do expect(find_field("project_visibility_level_#{level}")).to be_checked end @@ -91,7 +91,7 @@ RSpec.describe 'New project', :js do it "saves visibility level #{level} on validation error" do visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' choose(key) click_button('Create project') @@ -111,7 +111,7 @@ RSpec.describe 'New project', :js do context 'when admin mode is enabled', :enable_admin_mode do it 'has private selected' do visit new_project_path(namespace_id: group.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' page.within('#blank-project-pane') do expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked @@ -138,7 +138,7 @@ RSpec.describe 'New project', :js do context 'when admin mode is enabled', :enable_admin_mode do it 'has private selected' do visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE }) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' page.within('#blank-project-pane') do expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked @@ -159,7 +159,7 @@ RSpec.describe 'New project', :js do context 'Readme selector' do it 'shows the initialize with Readme checkbox on "Blank project" tab' do visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect(page).to have_css('input#project_initialize_with_readme') expect(page).to have_content('Initialize repository with a README') @@ -167,7 +167,7 @@ RSpec.describe 'New project', :js do it 'does not show the initialize with Readme checkbox on "Create from template" tab' do visit new_project_path - find('[data-qa-panel-name="create_from_template"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create from template' first('.choose-template').click page.within '.project-fields-form' do @@ -178,7 +178,7 @@ RSpec.describe 'New project', :js do it 'does not show the initialize with Readme checkbox on "Import project" tab' do visit new_project_path - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Import project' first('.js-import-git-toggle-button').click page.within '#import-project-pane' do @@ -192,7 +192,7 @@ RSpec.describe 'New project', :js do context 'with user namespace' do before do visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'selects the user namespace' do @@ -208,7 +208,7 @@ RSpec.describe 'New project', :js do before do group.add_owner(user) visit new_project_path(namespace_id: group.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'selects the group namespace' do @@ -225,7 +225,7 @@ RSpec.describe 'New project', :js do before do group.add_maintainer(user) visit new_project_path(namespace_id: subgroup.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'selects the group namespace' do @@ -245,7 +245,7 @@ RSpec.describe 'New project', :js do internal_group.add_owner(user) private_group.add_owner(user) visit new_project_path(namespace_id: public_group.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'enables the correct visibility options' do @@ -275,7 +275,7 @@ RSpec.describe 'New project', :js do context 'Import project options', :js do before do visit new_project_path - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Import project' end context 'from git repository url, "Repo by URL"' do @@ -351,7 +351,7 @@ RSpec.describe 'New project', :js do before do group.add_developer(user) visit new_project_path(namespace_id: group.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'selects the group namespace' do diff --git a/spec/features/projects/packages_spec.rb b/spec/features/projects/packages_spec.rb index 9b1e87192f5..7fcc8200b1c 100644 --- a/spec/features/projects/packages_spec.rb +++ b/spec/features/projects/packages_spec.rb @@ -27,10 +27,6 @@ RSpec.describe 'Packages' do context 'when feature is available', :js do before do - # we are simply setting the featrure flag to false because the new UI has nothing to test yet - # when the refactor is complete or almost complete we will turn on the feature tests - # see https://gitlab.com/gitlab-org/gitlab/-/issues/330846 for status of this work - stub_feature_flags(package_list_apollo: false) visit_project_packages end diff --git a/spec/features/projects/pages/user_adds_domain_spec.rb b/spec/features/projects/pages/user_adds_domain_spec.rb index de9effe3dc7..06f130ae69c 100644 --- a/spec/features/projects/pages/user_adds_domain_spec.rb +++ b/spec/features/projects/pages/user_adds_domain_spec.rb @@ -14,6 +14,8 @@ RSpec.describe 'User adds pages domain', :js do project.add_maintainer(user) sign_in(user) + + stub_feature_flags(bootstrap_confirmation_modals: false) end context 'when pages are exposed on external HTTP address', :http_pages_enabled do diff --git a/spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb b/spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb index cf8438d5e6f..a3fc5804e13 100644 --- a/spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb +++ b/spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb @@ -14,6 +14,7 @@ RSpec.describe "Pages with Let's Encrypt", :https_pages_enabled do before do allow(Gitlab.config.pages).to receive(:enabled).and_return(true) stub_lets_encrypt_settings + stub_feature_flags(bootstrap_confirmation_modals: false) project.add_role(user, role) sign_in(user) diff --git a/spec/features/projects/pages/user_edits_settings_spec.rb b/spec/features/projects/pages/user_edits_settings_spec.rb index 71d4cce2784..1226e1dc2ed 100644 --- a/spec/features/projects/pages/user_edits_settings_spec.rb +++ b/spec/features/projects/pages/user_edits_settings_spec.rb @@ -176,6 +176,7 @@ RSpec.describe 'Pages edits pages settings', :js do describe 'Remove page' do context 'when pages are deployed' do before do + stub_feature_flags(bootstrap_confirmation_modals: false) project.mark_pages_as_deployed end diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 94e3331b173..9df430c0f78 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -11,6 +11,7 @@ RSpec.describe 'Pipeline Schedules', :js do context 'logged in as maintainer' do before do + stub_feature_flags(bootstrap_confirmation_modals: false) project.add_maintainer(user) gitlab_sign_in(user) end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index bd22c8632e4..e38c4989f26 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -317,6 +317,7 @@ RSpec.describe 'Pipelines', :js do end before do + stub_feature_flags(bootstrap_confirmation_modals: false) visit_project_pipelines end @@ -635,7 +636,7 @@ RSpec.describe 'Pipelines', :js do # header expect(page).to have_text("##{pipeline.id}") - expect(page).to have_selector(%Q(img[alt$="#{pipeline.user.name}'s avatar"])) + expect(page).to have_selector(%Q(img[src="#{pipeline.user.avatar_url}"])) expect(page).to have_link(pipeline.user.name, href: user_path(pipeline.user)) # stages diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb index 6bc4c66b8ca..98935fdf872 100644 --- a/spec/features/projects/releases/user_views_releases_spec.rb +++ b/spec/features/projects/releases/user_views_releases_spec.rb @@ -123,11 +123,11 @@ RSpec.describe 'User views releases', :js do 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) # The following properties (sometimes) include Git info, # so they are not rendered for Guest users - expect(page).not_to have_content(release_v3.name) - expect(page).not_to have_content(release_v3.tag) expect(page).not_to have_content(release_v3.commit.short_id) end end diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb index 4941b936c0c..d8de9e0449e 100644 --- a/spec/features/projects/settings/access_tokens_spec.rb +++ b/spec/features/projects/settings/access_tokens_spec.rb @@ -13,6 +13,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do end before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) end diff --git a/spec/features/projects/settings/packages_settings_spec.rb b/spec/features/projects/settings/packages_settings_spec.rb index 62f31fd027b..e70839e9720 100644 --- a/spec/features/projects/settings/packages_settings_spec.rb +++ b/spec/features/projects/settings/packages_settings_spec.rb @@ -19,7 +19,7 @@ RSpec.describe 'Projects > Settings > Packages', :js do let(:packages_enabled) { true } it 'displays the packages toggle button' do - expect(page).to have_button('Packages', class: 'gl-toggle') + expect(page).to have_selector('[data-testid="toggle-label"]', text: 'Packages') expect(page).to have_selector('input[name="project[packages_enabled]"] + button', visible: true) end end @@ -28,7 +28,7 @@ RSpec.describe 'Projects > Settings > Packages', :js do let(:packages_enabled) { false } it 'does not show up in UI' do - expect(page).not_to have_button('Packages', class: 'gl-toggle') + expect(page).not_to have_selector('[data-testid="toggle-label"]', text: 'Packages') end end end diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb index 0924f8320e1..0df4bd3f0d9 100644 --- a/spec/features/projects/settings/service_desk_setting_spec.rb +++ b/spec/features/projects/settings/service_desk_setting_spec.rb @@ -54,7 +54,7 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache do wait_for_requests project.reload - expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_incoming_address) + expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_custom_address) page.within '#js-service-desk' do fill_in('service-desk-project-suffix', with: 'foo') diff --git a/spec/features/projects/settings/user_searches_in_settings_spec.rb b/spec/features/projects/settings/user_searches_in_settings_spec.rb index 7ed96d01189..44b5464a1b0 100644 --- a/spec/features/projects/settings/user_searches_in_settings_spec.rb +++ b/spec/features/projects/settings/user_searches_in_settings_spec.rb @@ -7,6 +7,7 @@ RSpec.describe 'User searches project settings', :js do let_it_be(:project) { create(:project, :repository, namespace: user.namespace, pages_https_only: false) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) end diff --git a/spec/features/projects/settings/user_tags_project_spec.rb b/spec/features/projects/settings/user_tags_project_spec.rb index ff19ed22744..e9a2aa29352 100644 --- a/spec/features/projects/settings/user_tags_project_spec.rb +++ b/spec/features/projects/settings/user_tags_project_spec.rb @@ -2,22 +2,40 @@ require 'spec_helper' -RSpec.describe 'Projects > Settings > User tags a project' do +RSpec.describe 'Projects > Settings > User tags a project', :js do let(:user) { create(:user) } let(:project) { create(:project, namespace: user.namespace) } + let!(:topic) { create(:topic, name: 'topic1') } before do sign_in(user) visit edit_project_path(project) + wait_for_all_requests end - it 'sets project topics' do - fill_in 'Topics', with: 'topic1, topic2' + it 'select existing topic' do + fill_in class: 'gl-token-selector-input', with: 'topic1' + wait_for_all_requests + + find('.gl-avatar-labeled[entity-name="topic1"]').click + + page.within '.general-settings' do + click_button 'Save changes' + end + + expect(find('#project_topic_list_field', visible: :hidden).value).to eq 'topic1' + end + + it 'select new topic' do + fill_in class: 'gl-token-selector-input', with: 'topic2' + wait_for_all_requests + + click_button 'Add "topic2"' page.within '.general-settings' do click_button 'Save changes' end - expect(find_field('Topics').value).to eq 'topic1, topic2' + expect(find('#project_topic_list_field', visible: :hidden).value).to eq 'topic2' end end diff --git a/spec/features/projects/show/no_password_spec.rb b/spec/features/projects/show/no_password_spec.rb index d18ff75b324..ed06f4e14d3 100644 --- a/spec/features/projects/show/no_password_spec.rb +++ b/spec/features/projects/show/no_password_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.describe 'No Password Alert' do + let_it_be(:message_password_auth_enabled) { 'Your account is authenticated with SSO or SAML. To push and pull over HTTP with Git using this account, you must set a password or set up a Personal Access Token to use instead of a password. For more information, see Clone with HTTPS.' } + let_it_be(:message_password_auth_disabled) { 'Your account is authenticated with SSO or SAML. To push and pull over HTTP with Git using this account, you must set up a Personal Access Token to use instead of a password. For more information, see Clone with HTTPS.' } + let(:project) { create(:project, :repository, namespace: user.namespace) } context 'with internal auth enabled' do @@ -15,7 +18,7 @@ RSpec.describe 'No Password Alert' do let(:user) { create(:user) } it 'shows no alert' do - expect(page).not_to have_content "You won't be able to pull or push repositories via HTTP until you set a password on your account" + expect(page).not_to have_content message_password_auth_enabled end end @@ -23,7 +26,7 @@ RSpec.describe 'No Password Alert' do let(:user) { create(:user, password_automatically_set: true) } it 'shows a password alert' do - expect(page).to have_content "You won't be able to pull or push repositories via HTTP until you set a password on your account" + expect(page).to have_content message_password_auth_enabled end end end @@ -41,7 +44,7 @@ RSpec.describe 'No Password Alert' do gitlab_sign_in_via('saml', user, 'my-uid') visit project_path(project) - expect(page).to have_content "You won't be able to pull or push repositories via HTTP until you create a personal access token on your account" + expect(page).to have_content message_password_auth_disabled end end @@ -51,7 +54,7 @@ RSpec.describe 'No Password Alert' do gitlab_sign_in_via('saml', user, 'my-uid') visit project_path(project) - expect(page).not_to have_content "You won't be able to pull or push repositories via HTTP until you create a personal access token on your account" + expect(page).not_to have_content message_password_auth_disabled end end end diff --git a/spec/features/projects/show/user_uploads_files_spec.rb b/spec/features/projects/show/user_uploads_files_spec.rb index 51e41397439..92b54d83ef3 100644 --- a/spec/features/projects/show/user_uploads_files_spec.rb +++ b/spec/features/projects/show/user_uploads_files_spec.rb @@ -44,27 +44,27 @@ RSpec.describe 'Projects > Show > User uploads files' do end end - context 'when in the empty_repo_upload experiment' do - before do - stub_experiments(empty_repo_upload: :candidate) + context 'with an empty repo' do + let(:project) { create(:project, :empty_repo, creator: user) } + before do visit(project_path(project)) end - context 'with an empty repo' do - let(:project) { create(:project, :empty_repo, creator: user) } - - [true, false].each do |value| - include_examples 'uploads and commits a new text file via "upload file" button', drop: value - end + [true, false].each do |value| + include_examples 'uploads and commits a new text file via "upload file" button', drop: value end + end - context 'with a nonempty repo' do - let(:project) { create(:project, :repository, creator: user) } + context 'with a nonempty repo' do + let(:project) { create(:project, :repository, creator: user) } - [true, false].each do |value| - include_examples 'uploads and commits a new text file via "upload file" button', drop: value - end + before do + visit(project_path(project)) + end + + [true, false].each do |value| + include_examples 'uploads and commits a new text file via "upload file" button', drop: value end end end diff --git a/spec/features/projects/user_changes_project_visibility_spec.rb b/spec/features/projects/user_changes_project_visibility_spec.rb index 39b8cddd005..345d16982fd 100644 --- a/spec/features/projects/user_changes_project_visibility_spec.rb +++ b/spec/features/projects/user_changes_project_visibility_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'User changes public project visibility', :js do click_button 'Save changes' end - find('.js-confirm-danger-input').send_keys(project.path_with_namespace) + find('.js-legacy-confirm-danger-input').send_keys(project.path_with_namespace) page.within '.modal' do click_button 'Reduce project visibility' diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb index 5d482f9fbd0..f5e8a5e8fc1 100644 --- a/spec/features/projects/user_creates_project_spec.rb +++ b/spec/features/projects/user_creates_project_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'User creates a project', :js do it 'creates a new project' do visit(new_project_path) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' fill_in(:project_name, with: 'Empty') expect(page).to have_checked_field 'Initialize repository with a README' @@ -38,7 +38,7 @@ RSpec.describe 'User creates a project', :js do visit(new_project_path) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' fill_in(:project_name, with: 'With initial commits') expect(page).to have_checked_field 'Initialize repository with a README' @@ -67,7 +67,7 @@ RSpec.describe 'User creates a project', :js do it 'creates a new project' do visit(new_project_path) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' fill_in :project_name, with: 'A Subgroup Project' fill_in :project_path, with: 'a-subgroup-project' @@ -96,7 +96,7 @@ RSpec.describe 'User creates a project', :js do it 'creates a new project' do visit(new_project_path) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' fill_in :project_name, with: 'a-new-project' fill_in :project_path, with: 'a-new-project' diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 59ad7d31ea7..c4619b5498e 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'Project' do shared_examples 'creates from template' do |template, sub_template_tab = nil| it "is created from template", :js do - find('[data-qa-panel-name="create_from_template"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create from template' find(".project-template #{sub_template_tab}").click if sub_template_tab find("label[for=#{template.name}]").click fill_in("project_name", with: template.name) @@ -133,7 +133,7 @@ RSpec.describe 'Project' do visit path expect(page).to have_selector('[data-testid="project_topic_list"]') - expect(page).to have_link('topic1', href: explore_projects_path(topic: 'topic1')) + expect(page).to have_link('topic1', href: topic_explore_projects_path(topic_name: 'topic1')) end it 'shows up to 3 project topics' do @@ -142,9 +142,9 @@ RSpec.describe 'Project' do visit path expect(page).to have_selector('[data-testid="project_topic_list"]') - expect(page).to have_link('topic1', href: explore_projects_path(topic: 'topic1')) - expect(page).to have_link('topic2', href: explore_projects_path(topic: 'topic2')) - expect(page).to have_link('topic3', href: explore_projects_path(topic: 'topic3')) + expect(page).to have_link('topic1', href: topic_explore_projects_path(topic_name: 'topic1')) + expect(page).to have_link('topic2', href: topic_explore_projects_path(topic_name: 'topic2')) + expect(page).to have_link('topic3', href: topic_explore_projects_path(topic_name: 'topic3')) expect(page).to have_content('+ 1 more') end end @@ -257,7 +257,7 @@ RSpec.describe 'Project' do end it 'deletes a project', :sidekiq_inline do - expect { remove_with_confirm('Delete project', project.path, 'Yes, delete project') }.to change { Project.count }.by(-1) + expect { remove_with_confirm('Delete project', project.path_with_namespace, 'Yes, delete project') }.to change { Project.count }.by(-1) expect(page).to have_content "Project '#{project.full_name}' is in the process of being deleted." expect(Project.all.count).to be_zero expect(project.issues).to be_empty diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb index d679e4dbb99..610a80eb12c 100644 --- a/spec/features/signed_commits_spec.rb +++ b/spec/features/signed_commits_spec.rb @@ -11,6 +11,7 @@ RSpec.describe 'GPG signed commits' do perform_enqueued_jobs do create :gpg_key, key: GpgHelpers::User1.public_key, user: user + user.reload # necessary to reload the association with gpg_keys end visit project_commit_path(project, ref) @@ -114,6 +115,19 @@ RSpec.describe 'GPG signed commits' do end end + it 'unverified signature: commit contains multiple GPG signatures' do + user_1_key + + visit project_commit_path(project, GpgHelpers::MULTIPLE_SIGNATURES_SHA) + wait_for_all_requests + + page.find('.gpg-status-box', text: 'Unverified').click + + within '.popover' do + expect(page).to have_content "This commit was signed with multiple signatures." + end + end + it 'verified and the gpg user has a gitlab profile' do user_1_key @@ -168,7 +182,7 @@ RSpec.describe 'GPG signed commits' do page.find('.gpg-status-box', text: 'Unverified').click within '.popover' do - expect(page).to have_content 'This commit was signed with an unverified signature' + expect(page).to have_content 'This commit was signed with multiple signatures.' end end end diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index fc88cd9205c..6bd31d7314c 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -18,6 +18,7 @@ RSpec.describe 'Comments on personal snippets', :js do end before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in user visit snippet_path(snippet) diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb index ca050daa62a..82fe895d397 100644 --- a/spec/features/snippets/user_creates_snippet_spec.rb +++ b/spec/features/snippets/user_creates_snippet_spec.rb @@ -16,6 +16,7 @@ RSpec.describe 'User creates snippet', :js do let(:snippet_title_field) { 'snippet-title' } before do + stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) visit new_snippet_path diff --git a/spec/features/topic_show_spec.rb b/spec/features/topic_show_spec.rb new file mode 100644 index 00000000000..3a9865a6503 --- /dev/null +++ b/spec/features/topic_show_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Topic show page' do + let_it_be(:topic) { create(:topic, name: 'my-topic', description: 'This is **my** topic https://google.com/ :poop: ```\ncode\n```', avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) } + + context 'when topic does not exist' do + let(:path) { topic_explore_projects_path(topic_name: 'non-existing') } + + it 'renders 404' do + visit path + + expect(status_code).to eq(404) + end + end + + context 'when topic exists' do + before do + visit topic_explore_projects_path(topic_name: topic.name) + end + + it 'shows name, avatar and description as markdown' do + expect(page).to have_content(topic.name) + expect(page).to have_selector('.avatar-container > img.topic-avatar') + expect(find('.topic-description')).to have_selector('p > strong') + expect(find('.topic-description')).to have_selector('p > a[rel]') + expect(find('.topic-description')).to have_selector('p > gl-emoji') + expect(find('.topic-description')).to have_selector('p > code') + end + + context 'with associated projects' do + let!(:project) { create(:project, :public, topic_list: topic.name) } + + it 'shows project list' do + visit topic_explore_projects_path(topic_name: topic.name) + + expect(find('.projects-list .project-name')).to have_content(project.name) + end + end + + context 'without associated projects' do + it 'shows correct empty state message' do + expect(page).to have_content('Explore public groups to find projects to contribute to.') + end + end + end +end diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 6fa805d8c74..2ddd86dd807 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -72,6 +72,7 @@ RSpec.describe 'Triggers', :js do describe 'trigger "Revoke" workflow' do before do + stub_feature_flags(bootstrap_confirmation_modals: false) create(:ci_trigger, owner: user2, project: @project, description: trigger_title) visit project_settings_ci_cd_path(@project) end diff --git a/spec/features/users/confirmation_spec.rb b/spec/features/users/confirmation_spec.rb new file mode 100644 index 00000000000..aaa49c75223 --- /dev/null +++ b/spec/features/users/confirmation_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User confirmation' do + describe 'resend confirmation instructions' do + context 'when recaptcha is enabled' do + before do + stub_application_setting(recaptcha_enabled: true) + allow(Gitlab::Recaptcha).to receive(:load_configurations!) + visit new_user_confirmation_path + end + + it 'renders recaptcha' do + expect(page).to have_css('.g-recaptcha') + end + end + + context 'when recaptcha is not enabled' do + before do + stub_application_setting(recaptcha_enabled: false) + visit new_user_confirmation_path + end + + it 'does not render recaptcha' do + expect(page).not_to have_css('.g-recaptcha') + end + end + end +end diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 10c1c2cb26e..66ebd00d368 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -753,7 +753,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end end - context 'when terms are enforced' do + context 'when terms are enforced', :js do let(:user) { create(:user) } before do @@ -802,7 +802,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end context 'when the user did not enable 2FA' do - it 'asks to set 2FA before asking to accept the terms', :js do + it 'asks to set 2FA before asking to accept the terms' do expect(authentication_metrics) .to increment(:user_authenticated_counter) @@ -887,7 +887,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end end - context 'when the user does not have an email configured', :js do + context 'when the user does not have an email configured' do let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml', email: 'temp-email-for-oauth-user@gitlab.localhost') } before do diff --git a/spec/features/users/password_spec.rb b/spec/features/users/password_spec.rb new file mode 100644 index 00000000000..793a11c616e --- /dev/null +++ b/spec/features/users/password_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User password' do + describe 'send password reset' do + context 'when recaptcha is enabled' do + before do + stub_application_setting(recaptcha_enabled: true) + allow(Gitlab::Recaptcha).to receive(:load_configurations!) + visit new_user_password_path + end + + it 'renders recaptcha' do + expect(page).to have_css('.g-recaptcha') + end + end + + context 'when recaptcha is not enabled' do + before do + stub_application_setting(recaptcha_enabled: false) + visit new_user_password_path + end + + it 'does not render recaptcha' do + expect(page).not_to have_css('.g-recaptcha') + end + end + end +end diff --git a/spec/features/users/terms_spec.rb b/spec/features/users/terms_spec.rb index 8ba79d77c22..7cfe74f8aa9 100644 --- a/spec/features/users/terms_spec.rb +++ b/spec/features/users/terms_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Users > Terms' do +RSpec.describe 'Users > Terms', :js do include TermsHelper let!(:term) { create(:term, terms: 'By accepting, you promise to be nice!') } diff --git a/spec/finders/autocomplete/routes_finder_spec.rb b/spec/finders/autocomplete/routes_finder_spec.rb new file mode 100644 index 00000000000..c5b040a5640 --- /dev/null +++ b/spec/finders/autocomplete/routes_finder_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Autocomplete::RoutesFinder do + describe '#execute' do + let_it_be(:user) { create(:user, username: 'user_path') } + let_it_be(:admin) { create(:admin) } + let_it_be(:group) { create(:group, path: 'path1') } + let_it_be(:group2) { create(:group, path: 'path2') } + let_it_be(:group3) { create(:group, path: 'not-matching') } + let_it_be(:project) { create(:project, path: 'path3', namespace: user.namespace) } + let_it_be(:project2) { create(:project, path: 'path4') } + let_it_be(:project_namespace) { create(:project_namespace, parent: group, path: 'path5') } + + let(:current_user) { user } + let(:search) { 'path' } + + before do + group.add_owner(user) + end + + context 'for NamespacesOnly' do + subject { Autocomplete::RoutesFinder::NamespacesOnly.new(current_user, search: search).execute } + + let(:user_route) { Route.find_by_path(user.username) } + + it 'finds only user namespace and groups matching the search excluding project namespaces' do + is_expected.to match_array([group.route, user_route]) + end + + context 'when user is admin' do + let(:current_user) { admin } + + it 'finds all namespaces matching the search excluding project namespaces' do + is_expected.to match_array([group.route, group2.route, user_route]) + end + end + end + + context 'for ProjectsOnly' do + subject { Autocomplete::RoutesFinder::ProjectsOnly.new(current_user, search: 'path').execute } + + it 'finds only matching projects the user has access to' do + is_expected.to match_array([project.route]) + end + + context 'when user is admin' do + let(:current_user) { admin } + + it 'finds all projects matching the search' do + is_expected.to match_array([project.route, project2.route]) + end + end + end + end +end diff --git a/spec/finders/branches_finder_spec.rb b/spec/finders/branches_finder_spec.rb index f9d525c33a4..11b7ab08fb2 100644 --- a/spec/finders/branches_finder_spec.rb +++ b/spec/finders/branches_finder_spec.rb @@ -208,10 +208,10 @@ RSpec.describe BranchesFinder do context 'by page_token only' do let(:params) { { page_token: 'feature' } } - it 'returns nothing' do - result = subject - - expect(result.count).to eq(0) + it 'raises an error' do + expect do + subject + end.to raise_error(Gitlab::Git::CommandError, '13:could not find page token.') end end diff --git a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb index 8a802e9660b..a7cf041f553 100644 --- a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb +++ b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb @@ -135,86 +135,6 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do end context 'when pipelines exist for the branch and merge request' do - shared_examples 'returns all pipelines for merge request' do - it 'returns merge request pipeline first' do - expect(subject.all).to eq([detached_merge_request_pipeline, branch_pipeline]) - end - - context 'when there are a branch pipeline and a merge request pipeline' do - let!(:branch_pipeline_2) do - create(:ci_pipeline, source: :push, project: project, - ref: source_ref, sha: shas.first) - end - - let!(:detached_merge_request_pipeline_2) do - create(:ci_pipeline, source: :merge_request_event, project: project, - ref: source_ref, sha: shas.first, merge_request: merge_request) - end - - it 'returns merge request pipelines first' do - expect(subject.all) - .to eq([detached_merge_request_pipeline_2, - detached_merge_request_pipeline, - branch_pipeline_2, - branch_pipeline]) - end - end - - context 'when there are multiple merge request pipelines from the same branch' do - let!(:branch_pipeline_2) do - create(:ci_pipeline, source: :push, project: project, - ref: source_ref, sha: shas.first) - end - - let!(:branch_pipeline_with_sha_not_belonging_to_merge_request) do - create(:ci_pipeline, source: :push, project: project, ref: source_ref) - end - - let!(:detached_merge_request_pipeline_2) do - create(:ci_pipeline, source: :merge_request_event, project: project, - ref: source_ref, sha: shas.first, merge_request: merge_request_2) - end - - let(:merge_request_2) do - create(:merge_request, source_project: project, source_branch: source_ref, - target_project: project, target_branch: 'stable') - end - - before do - shas.each.with_index do |sha, index| - create(:merge_request_diff_commit, - merge_request_diff: merge_request_2.merge_request_diff, - sha: sha, relative_order: index) - end - end - - it 'returns only related merge request pipelines' do - expect(subject.all) - .to eq([detached_merge_request_pipeline, - branch_pipeline_2, - branch_pipeline]) - - expect(described_class.new(merge_request_2, nil).all) - .to match_array([detached_merge_request_pipeline_2, branch_pipeline_2, branch_pipeline]) - end - end - - context 'when detached merge request pipeline is run on head ref of the merge request' do - let!(:detached_merge_request_pipeline) do - create(:ci_pipeline, source: :merge_request_event, project: project, - ref: merge_request.ref_path, sha: shas.second, merge_request: merge_request) - end - - it 'sets the head ref of the merge request to the pipeline ref' do - expect(detached_merge_request_pipeline.ref).to match(%r{refs/merge-requests/\d+/head}) - end - - it 'includes the detached merge request pipeline even though the ref is custom path' do - expect(merge_request.all_pipelines).to include(detached_merge_request_pipeline) - end - end - end - let(:source_ref) { 'feature' } let(:target_ref) { 'master' } @@ -240,20 +160,76 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do let(:project) { create(:project, :repository) } let(:shas) { project.repository.commits(source_ref, limit: 2).map(&:id) } - context 'when `decomposed_ci_query_in_pipelines_for_merge_request_finder` feature flag enabled' do - before do - stub_feature_flags(decomposed_ci_query_in_pipelines_for_merge_request_finder: merge_request.target_project) + it 'returns merge request pipeline first' do + expect(subject.all).to match_array([detached_merge_request_pipeline, branch_pipeline]) + end + + context 'when there are a branch pipeline and a merge request pipeline' do + let!(:branch_pipeline_2) do + create(:ci_pipeline, source: :push, project: project, + ref: source_ref, sha: shas.first) + end + + let!(:detached_merge_request_pipeline_2) do + create(:ci_pipeline, source: :merge_request_event, project: project, + ref: source_ref, sha: shas.first, merge_request: merge_request) end - it_behaves_like 'returns all pipelines for merge request' + it 'returns merge request pipelines first' do + expect(subject.all) + .to match_array([detached_merge_request_pipeline_2, detached_merge_request_pipeline, branch_pipeline_2, branch_pipeline]) + end end - context 'when `decomposed_ci_query_in_pipelines_for_merge_request_finder` feature flag disabled' do + context 'when there are multiple merge request pipelines from the same branch' do + let!(:branch_pipeline_2) do + create(:ci_pipeline, source: :push, project: project, + ref: source_ref, sha: shas.first) + end + + let!(:branch_pipeline_with_sha_not_belonging_to_merge_request) do + create(:ci_pipeline, source: :push, project: project, ref: source_ref) + end + + let!(:detached_merge_request_pipeline_2) do + create(:ci_pipeline, source: :merge_request_event, project: project, + ref: source_ref, sha: shas.first, merge_request: merge_request_2) + end + + let(:merge_request_2) do + create(:merge_request, source_project: project, source_branch: source_ref, + target_project: project, target_branch: 'stable') + end + before do - stub_feature_flags(decomposed_ci_query_in_pipelines_for_merge_request_finder: false) + shas.each.with_index do |sha, index| + create(:merge_request_diff_commit, + merge_request_diff: merge_request_2.merge_request_diff, + sha: sha, relative_order: index) + end end - it_behaves_like 'returns all pipelines for merge request' + it 'returns only related merge request pipelines' do + expect(subject.all).to match_array([detached_merge_request_pipeline, branch_pipeline_2, branch_pipeline]) + + expect(described_class.new(merge_request_2, nil).all) + .to match_array([detached_merge_request_pipeline_2, branch_pipeline_2, branch_pipeline]) + end + end + + context 'when detached merge request pipeline is run on head ref of the merge request' do + let!(:detached_merge_request_pipeline) do + create(:ci_pipeline, source: :merge_request_event, project: project, + ref: merge_request.ref_path, sha: shas.second, merge_request: merge_request) + end + + it 'sets the head ref of the merge request to the pipeline ref' do + expect(detached_merge_request_pipeline.ref).to match(%r{refs/merge-requests/\d+/head}) + end + + it 'includes the detached merge request pipeline even though the ref is custom path' do + expect(merge_request.all_pipelines).to include(detached_merge_request_pipeline) + end end end end diff --git a/spec/finders/clusters/agent_authorizations_finder_spec.rb b/spec/finders/clusters/agent_authorizations_finder_spec.rb new file mode 100644 index 00000000000..687906db0d7 --- /dev/null +++ b/spec/finders/clusters/agent_authorizations_finder_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::AgentAuthorizationsFinder do + describe '#execute' do + let_it_be(:top_level_group) { create(:group) } + let_it_be(:subgroup1) { create(:group, parent: top_level_group) } + let_it_be(:subgroup2) { create(:group, parent: subgroup1) } + let_it_be(:bottom_level_group) { create(:group, parent: subgroup2) } + + let_it_be(:agent_configuration_project) { create(:project, namespace: subgroup1) } + let_it_be(:requesting_project, reload: true) { create(:project, namespace: bottom_level_group) } + + let_it_be(:staging_agent) { create(:cluster_agent, project: agent_configuration_project) } + let_it_be(:production_agent) { create(:cluster_agent, project: agent_configuration_project) } + + subject { described_class.new(requesting_project).execute } + + shared_examples_for 'access_as' do + let(:config) { { access_as: { access_as => {} } } } + + context 'agent' do + let(:access_as) { :agent } + + it { is_expected.to match_array [authorization] } + end + + context 'impersonate' do + let(:access_as) { :impersonate } + + it { is_expected.to be_empty } + end + + context 'ci_user' do + let(:access_as) { :ci_user } + + it { is_expected.to be_empty } + end + + context 'ci_job' do + let(:access_as) { :ci_job } + + it { is_expected.to be_empty } + end + end + + describe 'project authorizations' do + context 'agent configuration project does not share a root namespace with the given project' do + let(:unrelated_agent) { create(:cluster_agent) } + + before do + create(:agent_project_authorization, agent: unrelated_agent, project: requesting_project) + end + + it { is_expected.to be_empty } + end + + context 'with project authorizations present' do + let!(:authorization) { create(:agent_project_authorization, agent: production_agent, project: requesting_project) } + + it { is_expected.to match_array [authorization] } + end + + context 'with overlapping authorizations' do + let!(:agent) { create(:cluster_agent, project: requesting_project) } + let!(:project_authorization) { create(:agent_project_authorization, agent: agent, project: requesting_project) } + let!(:group_authorization) { create(:agent_group_authorization, agent: agent, group: bottom_level_group) } + + it { is_expected.to match_array [project_authorization] } + end + + it_behaves_like 'access_as' do + let!(:authorization) { create(:agent_project_authorization, agent: production_agent, project: requesting_project, config: config) } + end + end + + describe 'implicit authorizations' do + let!(:associated_agent) { create(:cluster_agent, project: requesting_project) } + + it 'returns authorizations for agents directly associated with the project' do + expect(subject.count).to eq(1) + + authorization = subject.first + expect(authorization).to be_a(Clusters::Agents::ImplicitAuthorization) + expect(authorization.agent).to eq(associated_agent) + end + end + + describe 'authorized groups' do + context 'agent configuration project is outside the requesting project hierarchy' do + let(:unrelated_agent) { create(:cluster_agent) } + + before do + create(:agent_group_authorization, agent: unrelated_agent, group: top_level_group) + end + + it { is_expected.to be_empty } + end + + context 'multiple agents are authorized for the same group' do + let!(:staging_auth) { create(:agent_group_authorization, agent: staging_agent, group: bottom_level_group) } + let!(:production_auth) { create(:agent_group_authorization, agent: production_agent, group: bottom_level_group) } + + it 'returns authorizations for all agents' do + expect(subject).to contain_exactly(staging_auth, production_auth) + end + end + + context 'a single agent is authorized to more than one matching group' do + let!(:bottom_level_auth) { create(:agent_group_authorization, agent: production_agent, group: bottom_level_group) } + let!(:top_level_auth) { create(:agent_group_authorization, agent: production_agent, group: top_level_group) } + + it 'picks the authorization for the closest group to the requesting project' do + expect(subject).to contain_exactly(bottom_level_auth) + end + end + + it_behaves_like 'access_as' do + let!(:authorization) { create(:agent_group_authorization, agent: production_agent, group: top_level_group, config: config) } + end + end + end +end diff --git a/spec/finders/environments/environments_by_deployments_finder_spec.rb b/spec/finders/environments/environments_by_deployments_finder_spec.rb index 1b86aced67d..7804ffa4ef1 100644 --- a/spec/finders/environments/environments_by_deployments_finder_spec.rb +++ b/spec/finders/environments/environments_by_deployments_finder_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Environments::EnvironmentsByDeploymentsFinder do project.add_maintainer(user) end - describe '#execute' do + shared_examples 'execute' do context 'tagged deployment' do let(:environment_two) { create(:environment, project: project) } # Environments need to include commits, so rewind two commits to fit @@ -124,4 +124,16 @@ RSpec.describe Environments::EnvironmentsByDeploymentsFinder do end end end + + describe "#execute" do + include_examples 'execute' + + context 'when environments_by_deployments_finder_exists_optimization is disabled' do + before do + stub_feature_flags(environments_by_deployments_finder_exists_optimization: false) + end + + include_examples 'execute' + end + end end diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb index 749e319f9c7..aa7d32e51ac 100644 --- a/spec/finders/members_finder_spec.rb +++ b/spec/finders/members_finder_spec.rb @@ -202,13 +202,5 @@ RSpec.describe MembersFinder, '#execute' do end it_behaves_like 'with invited_groups param' - - context 'when feature flag :linear_members_finder_ancestor_scopes is disabled' do - before do - stub_feature_flags(linear_members_finder_ancestor_scopes: false) - end - - it_behaves_like 'with invited_groups param' - end end end diff --git a/spec/finders/tags_finder_spec.rb b/spec/finders/tags_finder_spec.rb index fe015d53ac9..acc86547271 100644 --- a/spec/finders/tags_finder_spec.rb +++ b/spec/finders/tags_finder_spec.rb @@ -7,13 +7,8 @@ RSpec.describe TagsFinder do let_it_be(:project) { create(:project, :repository) } let_it_be(:repository) { project.repository } - def load_tags(params) - tags_finder = described_class.new(repository, params) - tags, error = tags_finder.execute - - expect(error).to eq(nil) - - tags + def load_tags(params, gitaly_pagination: false) + described_class.new(repository, params).execute(gitaly_pagination: gitaly_pagination) end describe '#execute' do @@ -101,15 +96,79 @@ RSpec.describe TagsFinder do end end + context 'with Gitaly pagination' do + subject { load_tags(params, gitaly_pagination: true) } + + context 'by page_token and per_page' do + let(:params) { { page_token: 'v1.0.0', per_page: 1 } } + + it 'filters tags' do + result = subject + + expect(result.map(&:name)).to eq(%w(v1.1.0)) + end + end + + context 'by next page_token and per_page' do + let(:params) { { page_token: 'v1.1.0', per_page: 2 } } + + it 'filters branches' do + result = subject + + expect(result.map(&:name)).to eq(%w(v1.1.1)) + end + end + + context 'by per_page only' do + let(:params) { { per_page: 2 } } + + it 'filters branches' do + result = subject + + expect(result.map(&:name)).to eq(%w(v1.0.0 v1.1.0)) + end + end + + context 'by page_token only' do + let(:params) { { page_token: 'feature' } } + + it 'raises an error' do + expect do + subject + end.to raise_error(Gitlab::Git::InvalidPageToken, 'Invalid page token: refs/tags/feature') + end + end + + context 'pagination and sort' do + context 'by per_page' do + let(:params) { { sort: 'updated_desc', per_page: 5 } } + + it 'filters branches' do + result = subject + + expect(result.map(&:name)).to eq(%w(v1.1.1 v1.1.0 v1.0.0)) + end + end + + context 'by page_token and per_page' do + let(:params) { { sort: 'updated_desc', page_token: 'v1.1.1', per_page: 2 } } + + it 'filters branches' do + result = subject + + expect(result.map(&:name)).to eq(%w(v1.1.0 v1.0.0)) + end + end + end + end + context 'when Gitaly is unavailable' do - it 'returns empty list of tags' do + it 'raises an exception' do expect(Gitlab::GitalyClient).to receive(:call).and_raise(GRPC::Unavailable) tags_finder = described_class.new(repository, {}) - tags, error = tags_finder.execute - expect(error).to be_a(Gitlab::Git::CommandError) - expect(tags).to eq([]) + expect { tags_finder.execute }.to raise_error(Gitlab::Git::CommandError) end end end diff --git a/spec/fixtures/api/schemas/analytics/cycle_analytics/summary.json b/spec/fixtures/api/schemas/analytics/cycle_analytics/summary.json index 73904438ede..296e18fca47 100644 --- a/spec/fixtures/api/schemas/analytics/cycle_analytics/summary.json +++ b/spec/fixtures/api/schemas/analytics/cycle_analytics/summary.json @@ -14,6 +14,9 @@ }, "unit": { "type": "string" + }, + "links": { + "type": "array" } }, "additionalProperties": false diff --git a/spec/fixtures/api/schemas/graphql/packages/package_details.json b/spec/fixtures/api/schemas/graphql/packages/package_details.json index 2824ca64325..9ef7f6c9271 100644 --- a/spec/fixtures/api/schemas/graphql/packages/package_details.json +++ b/spec/fixtures/api/schemas/graphql/packages/package_details.json @@ -12,7 +12,6 @@ "tags", "pipelines", "versions", - "metadata", "status", "canDestroy" ], @@ -47,7 +46,8 @@ "GENERIC", "GOLANG", "RUBYGEMS", - "DEBIAN" + "DEBIAN", + "HELM" ] }, "tags": { diff --git a/spec/fixtures/api/schemas/public_api/v4/deploy_key.json b/spec/fixtures/api/schemas/public_api/v4/deploy_key.json new file mode 100644 index 00000000000..3dbdfcc95a1 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/deploy_key.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "required": [ + "id", + "title", + "created_at", + "expires_at", + "key", + "fingerprint", + "projects_with_write_access" + ], + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" }, + "expires_at": { "type": ["string", "null"], "format": "date-time" }, + "key": { "type": "string" }, + "fingerprint": { "type": "string" }, + "projects_with_write_access": { + "type": "array", + "items": { "$ref": "project/identity.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/deploy_keys.json b/spec/fixtures/api/schemas/public_api/v4/deploy_keys.json new file mode 100644 index 00000000000..82ddbdddbee --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/deploy_keys.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "deploy_key.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/packages/npm_package_version.json b/spec/fixtures/api/schemas/public_api/v4/packages/npm_package_version.json index 3e74dc0a1c2..64969d71250 100644 --- a/spec/fixtures/api/schemas/public_api/v4/packages/npm_package_version.json +++ b/spec/fixtures/api/schemas/public_api/v4/packages/npm_package_version.json @@ -36,11 +36,11 @@ ".{1,}": { "type": "string" } } }, - "deprecated": { - "type": "object", - "patternProperties": { - ".{1,}": { "type": "string" } - } - } + "deprecated": { "type": "string"}, + "bin": { "type": "string" }, + "directories": { "type": "array" }, + "engines": { "type": "object" }, + "_hasShrinkwrap": { "type": "boolean" }, + "additionalProperties": true } } diff --git a/spec/fixtures/bulk_imports/gz/milestones.ndjson.gz b/spec/fixtures/bulk_imports/gz/milestones.ndjson.gz deleted file mode 100644 index f959cd7a0bd..00000000000 Binary files a/spec/fixtures/bulk_imports/gz/milestones.ndjson.gz and /dev/null differ diff --git a/spec/fixtures/bulk_imports/milestones.ndjson b/spec/fixtures/bulk_imports/milestones.ndjson deleted file mode 100644 index 40523f276e7..00000000000 --- a/spec/fixtures/bulk_imports/milestones.ndjson +++ /dev/null @@ -1,5 +0,0 @@ -{"id":7642,"title":"v4.0","project_id":null,"description":"Et laudantium enim omnis ea reprehenderit iure.","due_date":null,"created_at":"2019-11-20T17:02:14.336Z","updated_at":"2019-11-20T17:02:14.336Z","state":"closed","iid":5,"start_date":null,"group_id":4351} -{"id":7641,"title":"v3.0","project_id":null,"description":"Et repellat culpa nemo consequatur ut reprehenderit.","due_date":null,"created_at":"2019-11-20T17:02:14.323Z","updated_at":"2019-11-20T17:02:14.323Z","state":"active","iid":4,"start_date":null,"group_id":4351} -{"id":7640,"title":"v2.0","project_id":null,"description":"Velit cupiditate est neque voluptates iste rem sunt.","due_date":null,"created_at":"2019-11-20T17:02:14.309Z","updated_at":"2019-11-20T17:02:14.309Z","state":"active","iid":3,"start_date":null,"group_id":4351} -{"id":7639,"title":"v1.0","project_id":null,"description":"Amet velit repellat ut rerum aut cum.","due_date":null,"created_at":"2019-11-20T17:02:14.296Z","updated_at":"2019-11-20T17:02:14.296Z","state":"active","iid":2,"start_date":null,"group_id":4351} -{"id":7638,"title":"v0.0","project_id":null,"description":"Ea quia asperiores ut modi dolorem sunt non numquam.","due_date":null,"created_at":"2019-11-20T17:02:14.282Z","updated_at":"2019-11-20T17:02:14.282Z","state":"active","iid":1,"start_date":null,"group_id":4351} diff --git a/spec/fixtures/emails/service_desk_all_quoted.eml b/spec/fixtures/emails/service_desk_all_quoted.eml new file mode 100644 index 00000000000..102ebf1f30e --- /dev/null +++ b/spec/fixtures/emails/service_desk_all_quoted.eml @@ -0,0 +1,22 @@ +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 +From: Jake the Dog +To: incoming+email-test-project_id-issue-@appmail.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 + +> This is an empty quote +> someone did forward this email without +> adding any new content. diff --git a/spec/fixtures/emails/service_desk_custom_address_no_key.eml b/spec/fixtures/emails/service_desk_custom_address_no_key.eml new file mode 100644 index 00000000000..4781e3d4fbd --- /dev/null +++ b/spec/fixtures/emails/service_desk_custom_address_no_key.eml @@ -0,0 +1,27 @@ +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: 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 +From: Jake the Dog +To: support+email-test-project_id-issue-@example.com +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 +``` + +/label ~label1 +/assign @user1 +/close diff --git a/spec/fixtures/emails/service_desk_forwarded.eml b/spec/fixtures/emails/service_desk_forwarded.eml index 56987972808..ab509cf55af 100644 --- a/spec/fixtures/emails/service_desk_forwarded.eml +++ b/spec/fixtures/emails/service_desk_forwarded.eml @@ -1,11 +1,11 @@ Delivered-To: incoming+email-test-project_id-issue-@appmail.adventuretime.ooo -Return-Path: +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 -From: Jake the Dog +From: Jake the Dog To: support@adventuretime.ooo Delivered-To: support@adventuretime.ooo Message-ID: diff --git a/spec/fixtures/error_tracking/browser_event.json b/spec/fixtures/error_tracking/browser_event.json new file mode 100644 index 00000000000..65918c3dc7a --- /dev/null +++ b/spec/fixtures/error_tracking/browser_event.json @@ -0,0 +1 @@ +{"sdk":{"name":"sentry.javascript.browser","version":"5.7.1","packages":[{"name":"npm:@sentry/browser","version":"5.7.1"}],"integrations":["InboundFilters","FunctionToString","TryCatch","Breadcrumbs","GlobalHandlers","LinkedErrors","UserAgent","Dedupe","ExtraErrorData","ReportingObserver","RewriteFrames","Vue"]},"level":"error","request":{"url":"http://localhost:5444/","headers":{"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:91.0) Gecko/20100101 Firefox/91.0"}},"event_id":"6a32dc45cd924196930e06aa21b48c8d","platform":"javascript","exception":{"values":[{"type":"TypeError","value":"Cannot read property 'filter' of undefined","mechanism":{"type":"generic","handled":true},"stacktrace":{"frames":[{"colno":34,"in_app":true,"lineno":6395,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":57,"in_app":true,"lineno":6362,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":13,"in_app":true,"lineno":3115,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"init"},{"colno":10,"in_app":true,"lineno":8399,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"Vue.prototype.$mount"},{"colno":3,"in_app":true,"lineno":4061,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"mountComponent"},{"colno":12,"in_app":true,"lineno":4456,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"Watcher"},{"colno":25,"in_app":true,"lineno":4467,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"get"},{"colno":10,"in_app":true,"lineno":4048,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"updateComponent"},{"colno":19,"in_app":true,"lineno":3933,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"lifecycleMixin/Vue.prototype._update"},{"colno":24,"in_app":true,"lineno":6477,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"patch"},{"colno":34,"in_app":true,"lineno":6395,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":64,"in_app":true,"lineno":78,"filename":"webpack-internal:///./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./pages/index.vue?vue&type=script&lang=js&","function":"data"}]}}]},"environment":"development"} \ No newline at end of file diff --git a/spec/fixtures/error_tracking/go_parsed_event.json b/spec/fixtures/error_tracking/go_parsed_event.json new file mode 100644 index 00000000000..9811fc261c0 --- /dev/null +++ b/spec/fixtures/error_tracking/go_parsed_event.json @@ -0,0 +1 @@ +{"contexts":{"device":{"arch":"amd64","num_cpu":20},"os":{"name":"linux"},"runtime":{"go_maxprocs":20,"go_numcgocalls":5,"go_numroutines":2,"name":"go","version":"go1.17.1"}},"environment":"Accumulate","event_id":"a6d33282b0d44ed1a5982c48b62e2a4e","level":"error","platform":"go","release":"accumulated@version unknown","sdk":{"name":"sentry.go","version":"0.11.0","integrations":["ContextifyFrames","Environment","IgnoreErrors","Modules"],"packages":[{"name":"sentry-go","version":"0.11.0"}]},"server_name":"Laurelin","user":{},"modules":{"github.com/AccumulateNetwork/accumulated":"(devel)","github.com/AccumulateNetwork/jsonrpc2/v15":"v15.0.0-20210802145948-43d2d974a106","github.com/AndreasBriese/bbloom":"v0.0.0-20190825152654-46b345b51c96","github.com/Workiva/go-datastructures":"v1.0.53","github.com/beorn7/perks":"v1.0.1","github.com/btcsuite/btcd":"v0.22.0-beta","github.com/cespare/xxhash":"v1.1.0","github.com/cespare/xxhash/v2":"v2.1.2","github.com/davecgh/go-spew":"v1.1.2-0.20180830191138-d8f796af33cc","github.com/dgraph-io/badger":"v1.6.2","github.com/dgraph-io/ristretto":"v0.0.4-0.20210122082011-bb5d392ed82d","github.com/dustin/go-humanize":"v1.0.0","github.com/fatih/color":"v1.13.0","github.com/fsnotify/fsnotify":"v1.5.1","github.com/getsentry/sentry-go":"v0.11.0","github.com/go-kit/kit":"v0.11.0","github.com/go-playground/locales":"v0.14.0","github.com/go-playground/universal-translator":"v0.18.0","github.com/go-playground/validator/v10":"v10.9.0","github.com/gogo/protobuf":"v1.3.2","github.com/golang/protobuf":"v1.5.2","github.com/golang/snappy":"v0.0.1","github.com/google/btree":"v1.0.0","github.com/google/orderedcode":"v0.0.1","github.com/google/uuid":"v1.3.0","github.com/gorilla/mux":"v1.8.0","github.com/gorilla/websocket":"v1.4.2","github.com/grpc-ecosystem/go-grpc-middleware":"v1.3.0","github.com/grpc-ecosystem/go-grpc-prometheus":"v1.2.0","github.com/hashicorp/hcl":"v1.0.0","github.com/leodido/go-urn":"v1.2.1","github.com/lib/pq":"v1.10.3","github.com/libp2p/go-buffer-pool":"v0.0.2","github.com/magiconair/properties":"v1.8.5","github.com/mattn/go-colorable":"v0.1.9","github.com/mattn/go-isatty":"v0.0.14","github.com/matttproud/golang_protobuf_extensions":"v1.0.1","github.com/minio/highwayhash":"v1.0.2","github.com/mitchellh/mapstructure":"v1.4.2","github.com/oasisprotocol/curve25519-voi":"v0.0.0-20210609091139-0a56a4bca00b","github.com/pelletier/go-toml":"v1.9.4","github.com/pkg/errors":"v0.9.1","github.com/pmezard/go-difflib":"v1.0.0","github.com/prometheus/client_golang":"v1.11.0","github.com/prometheus/client_model":"v0.2.0","github.com/prometheus/common":"v0.30.0","github.com/prometheus/procfs":"v0.7.3","github.com/rcrowley/go-metrics":"v0.0.0-20200313005456-10cdbea86bc0","github.com/rs/cors":"v1.8.0","github.com/rs/zerolog":"v1.24.0","github.com/spf13/afero":"v1.6.0","github.com/spf13/cast":"v1.4.1","github.com/spf13/cobra":"v1.2.1","github.com/spf13/jwalterweatherman":"v1.1.0","github.com/spf13/pflag":"v1.0.5","github.com/spf13/viper":"v1.8.1","github.com/stretchr/testify":"v1.7.0","github.com/subosito/gotenv":"v1.2.0","github.com/syndtr/goleveldb":"v1.0.1-0.20200815110645-5c35d600f0ca","github.com/tendermint/tendermint":"v0.35.0-rc1","github.com/tendermint/tm-db":"v0.6.4","github.com/ybbus/jsonrpc/v2":"v2.1.6","golang.org/x/crypto":"v0.0.0-20210711020723-a769d52b0f97","golang.org/x/net":"v0.0.0-20210805182204-aaa1db679c0d","golang.org/x/sys":"v0.0.0-20210917161153-d61c044b1678","golang.org/x/term":"v0.0.0-20201126162022-7de9c90e9dd1","golang.org/x/text":"v0.3.7","google.golang.org/genproto":"v0.0.0-20210602131652-f16073e35f0c","google.golang.org/grpc":"v1.40.0","google.golang.org/protobuf":"v1.27.1","gopkg.in/ini.v1":"v1.63.2","gopkg.in/yaml.v2":"v2.4.0","gopkg.in/yaml.v3":"v3.0.0-20210107192922-496545a6307b"},"exception":[{"type":"*errors.errorString","value":"Hello world","stacktrace":{"frames":[{"function":"main","module":"main","abs_path":"SRC/cmd/accumulated/main.go","lineno":37,"pre_context":["func init() {","\tcmdMain.PersistentFlags().StringVarP(&flagMain.WorkDir, \"work-dir\", \"w\", defaultWorkDir, \"Working directory for configuration and data\")","}","","func main() {"],"context_line":"\tcmdMain.Execute()","post_context":["}","","func printUsageAndExit1(cmd *cobra.Command, args []string) {","\tcmd.Usage()","\tos.Exit(1)"],"in_app":true},{"function":"(*Command).Execute","module":"github.com/spf13/cobra","abs_path":"GOPATH/pkg/mod/github.com/spf13/cobra@v1.2.1/command.go","lineno":902,"pre_context":["","// Execute uses the args (os.Args[1:] by default)","// and run through the command tree finding appropriate matches","// for commands and then corresponding flags.","func (c *Command) Execute() error {"],"context_line":"\t_, err := c.ExecuteC()","post_context":["\treturn err","}","","// ExecuteContextC is the same as ExecuteC(), but sets the ctx on the command.","// Retrieve ctx by calling cmd.Context() inside your *Run lifecycle or ValidArgs"],"in_app":true},{"function":"(*Command).ExecuteC","module":"github.com/spf13/cobra","abs_path":"GOPATH/pkg/mod/github.com/spf13/cobra@v1.2.1/command.go","lineno":974,"pre_context":["\t// if context is present on the parent command.","\tif cmd.ctx == nil {","\t\tcmd.ctx = c.ctx","\t}",""],"context_line":"\terr = cmd.execute(flags)","post_context":["\tif err != nil {","\t\t// Always show help if requested, even if SilenceErrors is in","\t\t// effect","\t\tif err == flag.ErrHelp {","\t\t\tcmd.HelpFunc()(cmd, args)"],"in_app":true},{"function":"(*Command).execute","module":"github.com/spf13/cobra","abs_path":"GOPATH/pkg/mod/github.com/spf13/cobra@v1.2.1/command.go","lineno":860,"pre_context":["\tif c.RunE != nil {","\t\tif err := c.RunE(c, argWoFlags); err != nil {","\t\t\treturn err","\t\t}","\t} else {"],"context_line":"\t\tc.Run(c, argWoFlags)","post_context":["\t}","\tif c.PostRunE != nil {","\t\tif err := c.PostRunE(c, argWoFlags); err != nil {","\t\t\treturn err","\t\t}"],"in_app":true},{"function":"runNode","module":"main","abs_path":"SRC/cmd/accumulated/cmd_run.go","lineno":76,"pre_context":["\t\tif err != nil {","\t\t\tfmt.Fprintf(os.Stderr, \"Error: configuring sentry: %v\\n\", err)","\t\t\tos.Exit(1)","\t\t}","\t\tdefer sentry.Flush(2 * time.Second)"],"context_line":"\t\tsentry.CaptureException(errors.New(\"Hello world\"))","post_context":["\t\t// sentry.CaptureMessage(\"Hello world\")","\t\tsentry.Flush(time.Second)","\t}","","\tdbPath := filepath.Join(config.RootDir, \"valacc.db\")"],"in_app":true}]}}],"timestamp":"2021-10-08T19:49:21.932425444-05:00"} diff --git a/spec/fixtures/error_tracking/python_event.json b/spec/fixtures/error_tracking/python_event.json new file mode 100644 index 00000000000..4b27cb47e5b --- /dev/null +++ b/spec/fixtures/error_tracking/python_event.json @@ -0,0 +1 @@ +{"level":"error","exception":{"values":[{"module":null,"type":"ZeroDivisionError","value":"division by zero","mechanism":{"type":"django","handled":false},"stacktrace":{"frames":[{"filename":"django/core/handlers/exception.py","abs_path":"/Users/dzaporozhets/.asdf/installs/python/3.8.12/lib/python3.8/site-packages/django/core/handlers/exception.py","function":"inner","module":"django.core.handlers.exception","lineno":47,"pre_context":[" return inner"," else:"," @wraps(get_response)"," def inner(request):"," try:"],"context_line":" response = get_response(request)","post_context":[" except Exception as exc:"," response = response_for_exception(request, exc)"," return response"," return inner",""],"vars":{"request":"\u003cWSGIRequest: GET '/polls/'\u003e","exc":"ZeroDivisionError('division by zero')","get_response":"\u003cbound method BaseHandler._get_response of \u003cdjango.core.handlers.wsgi.WSGIHandler object at 0x10f012550\u003e\u003e"},"in_app":true},{"filename":"django/core/handlers/base.py","abs_path":"/Users/dzaporozhets/.asdf/installs/python/3.8.12/lib/python3.8/site-packages/django/core/handlers/base.py","function":"_get_response","module":"django.core.handlers.base","lineno":181,"pre_context":[" wrapped_callback = self.make_view_atomic(callback)"," # If it is an asynchronous view, run it in a subthread."," if asyncio.iscoroutinefunction(wrapped_callback):"," wrapped_callback = async_to_sync(wrapped_callback)"," try:"],"context_line":" response = wrapped_callback(request, *callback_args, **callback_kwargs)","post_context":[" except Exception as e:"," response = self.process_exception_by_middleware(e, request)"," if response is None:"," raise",""],"vars":{"self":"\u003cdjango.core.handlers.wsgi.WSGIHandler object at 0x10f012550\u003e","request":"\u003cWSGIRequest: GET '/polls/'\u003e","response":"None","callback":"\u003cfunction index at 0x10f7b6820\u003e","callback_args":[],"callback_kwargs":{},"middleware_method":"\u003cfunction CsrfViewMiddleware.process_view at 0x113853a60\u003e","wrapped_callback":"\u003cfunction index at 0x113d41040\u003e"},"in_app":true},{"filename":"polls/views.py","abs_path":"/Users/dzaporozhets/Projects/pysite/polls/views.py","function":"index","module":"polls.views","lineno":15,"pre_context":[" # We recommend adjusting this value in production."," traces_sample_rate=1.0,",")","","def index(request):"],"context_line":" division_by_zero = 1 / 0","post_context":[" return HttpResponse(\"Hello, world. You're at the polls index.\")"],"vars":{"request":"\u003cWSGIRequest: GET '/polls/'\u003e"},"in_app":true}]}}]},"event_id":"dbae4fc6415f408786174a929363d26f","timestamp":"2021-10-07T14:52:18.257544Z","breadcrumbs":{"values":[]},"transaction":"/polls/","contexts":{"trace":{"trace_id":"20b50c065f4f4b5d99862e5ea08b45aa","span_id":"a4ecb3118f7f9f5a","parent_span_id":"af50a83a73a41c28","op":"django.middleware","description":"django.middleware.clickjacking.XFrameOptionsMiddleware.__call__"},"runtime":{"name":"CPython","version":"3.8.12","build":"3.8.12 (default, Oct 6 2021, 13:48:19) \n[Clang 12.0.5 (clang-1205.0.22.9)]"}},"modules":{"urllib3":"1.26.7","sqlparse":"0.4.2","setuptools":"56.0.0","sentry-sdk":"1.4.3","pytz":"2021.3","pip":"21.1.1","django":"3.2.8","certifi":"2021.5.30","asgiref":"3.4.1"},"extra":{"sys.argv":["manage.py","runserver"]},"request":{"url":"http://localhost:8000/polls/","query_string":"","method":"GET","env":{"SERVER_NAME":"1.0.0.127.in-addr.arpa","SERVER_PORT":"8000"},"headers":{"Content-Length":"","Content-Type":"text/plain","Host":"localhost:8000","User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:92.0) Gecko/20100101 Firefox/92.0","Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8","Accept-Language":"en-US,en;q=0.5","Accept-Encoding":"gzip, deflate","Connection":"keep-alive","Cookie":"","Upgrade-Insecure-Requests":"1","Sec-Fetch-Dest":"document","Sec-Fetch-Mode":"navigate","Sec-Fetch-Site":"none","Sec-Fetch-User":"?1","Cache-Control":"max-age=0"}},"environment":"production","server_name":"DZ-GitLab-MBP-15.local","sdk":{"name":"sentry.python","version":"1.4.3","packages":[{"name":"pypi:sentry-sdk","version":"1.4.3"}],"integrations":["argv","atexit","dedupe","django","excepthook","logging","modules","stdlib","threading"]},"platform":"python","_meta":{"request":{"headers":{"Cookie":{"":{"rem":[["!config","x",0,2968]]}}}}}} \ No newline at end of file diff --git a/spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz b/spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz index e3ec4f603b9..e5f6f195fe5 100644 Binary files a/spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz and b/spec/fixtures/gitlab/import_export/lightweight_project_export.tar.gz differ diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json index fd4c2d55124..95f2ce45b46 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/project.json +++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json @@ -2795,11 +2795,7 @@ "sha": "bb5206fee213d983da88c47f9cf4cc6caf9c66dc", "message": "Feature conflict added\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-08-06T08:35:52.000+02:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-08-06T08:35:52.000+02:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -2815,11 +2811,7 @@ "sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e", "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T10:01:38.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T10:01:38.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -2835,11 +2827,7 @@ "sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d", "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:57:31.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:57:31.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -2855,11 +2843,7 @@ "sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9", "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:54:21.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:54:21.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -2875,11 +2859,7 @@ "sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08", "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:49:50.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:49:50.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -2895,11 +2875,7 @@ "sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8", "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:48:32.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:48:32.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -3291,11 +3267,7 @@ "relative_order": 0, "message": "Feature added\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:26:01.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:26:01.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -3562,11 +3534,7 @@ "sha": "94b8d581c48d894b86661718582fecbc5e3ed2eb", "message": "fixes #10\n", "authored_date": "2016-01-19T13:22:56.000+01:00", - "author_name": "James Lopez", - "author_email": "james@jameslopez.es", "committed_date": "2016-01-19T13:22:56.000+01:00", - "committer_name": "James Lopez", - "committer_email": "james@jameslopez.es", "commit_author": { "name": "James Lopez", "email": "james@jameslopez.es" @@ -3833,11 +3801,7 @@ "sha": "ddd4ff416a931589c695eb4f5b23f844426f6928", "message": "fixes #10\n", "authored_date": "2016-01-19T14:14:43.000+01:00", - "author_name": "James Lopez", - "author_email": "james@jameslopez.es", "committed_date": "2016-01-19T14:14:43.000+01:00", - "committer_name": "James Lopez", - "committer_email": "james@jameslopez.es", "commit_author": { "name": "James Lopez", "email": "james@jameslopez.es" @@ -3853,11 +3817,7 @@ "sha": "be93687618e4b132087f430a4d8fc3a609c9b77c", "message": "Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6", "authored_date": "2015-12-07T12:52:12.000+01:00", - "author_name": "Marin Jankovski", - "author_email": "marin@gitlab.com", "committed_date": "2015-12-07T12:52:12.000+01:00", - "committer_name": "Marin Jankovski", - "committer_email": "marin@gitlab.com", "commit_author": { "name": "Marin Jankovski", "email": "marin@gitlab.com" @@ -3873,11 +3833,7 @@ "sha": "048721d90c449b244b7b4c53a9186b04330174ec", "message": "LFS object pointer.\n", "authored_date": "2015-12-07T11:54:28.000+01:00", - "author_name": "Marin Jankovski", - "author_email": "maxlazio@gmail.com", "committed_date": "2015-12-07T11:54:28.000+01:00", - "committer_name": "Marin Jankovski", - "committer_email": "maxlazio@gmail.com", "commit_author": { "name": "Marin Jankovski", "email": "maxlazio@gmail.com" @@ -3893,11 +3849,7 @@ "sha": "5f923865dde3436854e9ceb9cdb7815618d4e849", "message": "GitLab currently doesn't support patches that involve a merge commit: add a commit here\n", "authored_date": "2015-11-13T16:27:12.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T16:27:12.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -3913,11 +3865,7 @@ "sha": "d2d430676773caa88cdaf7c55944073b2fd5561a", "message": "Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5", "authored_date": "2015-11-13T08:50:17.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T08:50:17.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -3933,11 +3881,7 @@ "sha": "2ea1f3dec713d940208fb5ce4a38765ecb5d3f73", "message": "Add GitLab SVG\n", "authored_date": "2015-11-13T08:39:43.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T08:39:43.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -3953,11 +3897,7 @@ "sha": "59e29889be61e6e0e5e223bfa9ac2721d31605b8", "message": "Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4", "authored_date": "2015-11-13T07:21:40.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T07:21:40.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -3973,11 +3913,7 @@ "sha": "66eceea0db202bb39c4e445e8ca28689645366c5", "message": "add spaces in whitespace file\n", "authored_date": "2015-11-13T06:01:27.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T06:01:27.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -3993,11 +3929,7 @@ "sha": "08f22f255f082689c0d7d39d19205085311542bc", "message": "remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n", "authored_date": "2015-11-13T06:00:16.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T06:00:16.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -4013,11 +3945,7 @@ "sha": "19e2e9b4ef76b422ce1154af39a91323ccc57434", "message": "Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3", "authored_date": "2015-11-13T05:23:14.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T05:23:14.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -4033,11 +3961,7 @@ "sha": "c642fe9b8b9f28f9225d7ea953fe14e74748d53b", "message": "add whitespace in empty\n", "authored_date": "2015-11-13T05:08:45.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T05:08:45.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -4053,11 +3977,7 @@ "sha": "9a944d90955aaf45f6d0c88f30e27f8d2c41cec0", "message": "add empty file\n", "authored_date": "2015-11-13T05:08:04.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T05:08:04.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -4073,11 +3993,7 @@ "sha": "c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd", "message": "Add ISO-8859 test file\n", "authored_date": "2015-08-25T17:53:12.000+02:00", - "author_name": "Stan Hu", - "author_email": "stanhu@packetzoom.com", "committed_date": "2015-08-25T17:53:12.000+02:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@packetzoom.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@packetzoom.com" @@ -4093,11 +4009,7 @@ "sha": "e56497bb5f03a90a51293fc6d516788730953899", "message": "Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n", "authored_date": "2015-01-10T22:23:29.000+01:00", - "author_name": "Sytse Sijbrandij", - "author_email": "sytse@gitlab.com", "committed_date": "2015-01-10T22:23:29.000+01:00", - "committer_name": "Sytse Sijbrandij", - "committer_email": "sytse@gitlab.com", "commit_author": { "name": "Sytse Sijbrandij", "email": "sytse@gitlab.com" @@ -4113,11 +4025,7 @@ "sha": "4cd80ccab63c82b4bad16faa5193fbd2aa06df40", "message": "add directory structure for tree_helper spec\n", "authored_date": "2015-01-10T21:28:18.000+01:00", - "author_name": "marmis85", - "author_email": "marmis85@gmail.com", "committed_date": "2015-01-10T21:28:18.000+01:00", - "committer_name": "marmis85", - "committer_email": "marmis85@gmail.com", "commit_author": { "name": "marmis85", "email": "marmis85@gmail.com" @@ -4133,11 +4041,7 @@ "sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e", "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T10:01:38.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T10:01:38.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -4153,11 +4057,7 @@ "sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d", "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:57:31.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:57:31.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -4173,11 +4073,7 @@ "sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9", "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:54:21.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:54:21.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -4193,11 +4089,7 @@ "sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08", "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:49:50.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:49:50.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -4213,11 +4105,7 @@ "sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8", "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:48:32.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:48:32.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -4678,11 +4566,7 @@ "sha": "0bfedc29d30280c7e8564e19f654584b459e5868", "message": "fixes #10\n", "authored_date": "2016-01-19T15:25:23.000+01:00", - "author_name": "James Lopez", - "author_email": "james@jameslopez.es", "committed_date": "2016-01-19T15:25:23.000+01:00", - "committer_name": "James Lopez", - "committer_email": "james@jameslopez.es", "commit_author": { "name": "James Lopez", "email": "james@jameslopez.es" @@ -4698,11 +4582,7 @@ "sha": "be93687618e4b132087f430a4d8fc3a609c9b77c", "message": "Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6", "authored_date": "2015-12-07T12:52:12.000+01:00", - "author_name": "Marin Jankovski", - "author_email": "marin@gitlab.com", "committed_date": "2015-12-07T12:52:12.000+01:00", - "committer_name": "Marin Jankovski", - "committer_email": "marin@gitlab.com", "commit_author": { "name": "Marin Jankovski", "email": "marin@gitlab.com" @@ -4718,11 +4598,7 @@ "sha": "048721d90c449b244b7b4c53a9186b04330174ec", "message": "LFS object pointer.\n", "authored_date": "2015-12-07T11:54:28.000+01:00", - "author_name": "Marin Jankovski", - "author_email": "maxlazio@gmail.com", "committed_date": "2015-12-07T11:54:28.000+01:00", - "committer_name": "Marin Jankovski", - "committer_email": "maxlazio@gmail.com", "commit_author": { "name": "Marin Jankovski", "email": "maxlazio@gmail.com" @@ -4738,11 +4614,7 @@ "sha": "5f923865dde3436854e9ceb9cdb7815618d4e849", "message": "GitLab currently doesn't support patches that involve a merge commit: add a commit here\n", "authored_date": "2015-11-13T16:27:12.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T16:27:12.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -4758,11 +4630,7 @@ "sha": "d2d430676773caa88cdaf7c55944073b2fd5561a", "message": "Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5", "authored_date": "2015-11-13T08:50:17.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T08:50:17.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -4778,11 +4646,7 @@ "sha": "2ea1f3dec713d940208fb5ce4a38765ecb5d3f73", "message": "Add GitLab SVG\n", "authored_date": "2015-11-13T08:39:43.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T08:39:43.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -4798,11 +4662,7 @@ "sha": "59e29889be61e6e0e5e223bfa9ac2721d31605b8", "message": "Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4", "authored_date": "2015-11-13T07:21:40.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T07:21:40.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -4818,11 +4678,7 @@ "sha": "66eceea0db202bb39c4e445e8ca28689645366c5", "message": "add spaces in whitespace file\n", "authored_date": "2015-11-13T06:01:27.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T06:01:27.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -4838,11 +4694,7 @@ "sha": "08f22f255f082689c0d7d39d19205085311542bc", "message": "remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n", "authored_date": "2015-11-13T06:00:16.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T06:00:16.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -4858,11 +4710,7 @@ "sha": "19e2e9b4ef76b422ce1154af39a91323ccc57434", "message": "Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3", "authored_date": "2015-11-13T05:23:14.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T05:23:14.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -4878,11 +4726,7 @@ "sha": "c642fe9b8b9f28f9225d7ea953fe14e74748d53b", "message": "add whitespace in empty\n", "authored_date": "2015-11-13T05:08:45.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T05:08:45.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -4898,11 +4742,7 @@ "sha": "9a944d90955aaf45f6d0c88f30e27f8d2c41cec0", "message": "add empty file\n", "authored_date": "2015-11-13T05:08:04.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T05:08:04.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -4918,11 +4758,7 @@ "sha": "c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd", "message": "Add ISO-8859 test file\n", "authored_date": "2015-08-25T17:53:12.000+02:00", - "author_name": "Stan Hu", - "author_email": "stanhu@packetzoom.com", "committed_date": "2015-08-25T17:53:12.000+02:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@packetzoom.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@packetzoom.com" @@ -4938,11 +4774,7 @@ "sha": "e56497bb5f03a90a51293fc6d516788730953899", "message": "Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n", "authored_date": "2015-01-10T22:23:29.000+01:00", - "author_name": "Sytse Sijbrandij", - "author_email": "sytse@gitlab.com", "committed_date": "2015-01-10T22:23:29.000+01:00", - "committer_name": "Sytse Sijbrandij", - "committer_email": "sytse@gitlab.com", "commit_author": { "name": "Sytse Sijbrandij", "email": "sytse@gitlab.com" @@ -4958,11 +4790,7 @@ "sha": "4cd80ccab63c82b4bad16faa5193fbd2aa06df40", "message": "add directory structure for tree_helper spec\n", "authored_date": "2015-01-10T21:28:18.000+01:00", - "author_name": "marmis85", - "author_email": "marmis85@gmail.com", "committed_date": "2015-01-10T21:28:18.000+01:00", - "committer_name": "marmis85", - "committer_email": "marmis85@gmail.com", "commit_author": { "name": "marmis85", "email": "marmis85@gmail.com" @@ -5307,11 +5135,7 @@ "sha": "97a0df9696e2aebf10c31b3016f40214e0e8f243", "message": "fixes #10\n", "authored_date": "2016-01-19T14:08:21.000+01:00", - "author_name": "James Lopez", - "author_email": "james@jameslopez.es", "committed_date": "2016-01-19T14:08:21.000+01:00", - "committer_name": "James Lopez", - "committer_email": "james@jameslopez.es", "commit_author": { "name": "James Lopez", "email": "james@jameslopez.es" @@ -5327,11 +5151,7 @@ "sha": "be93687618e4b132087f430a4d8fc3a609c9b77c", "message": "Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6", "authored_date": "2015-12-07T12:52:12.000+01:00", - "author_name": "Marin Jankovski", - "author_email": "marin@gitlab.com", "committed_date": "2015-12-07T12:52:12.000+01:00", - "committer_name": "Marin Jankovski", - "committer_email": "marin@gitlab.com", "commit_author": { "name": "Marin Jankovski", "email": "marin@gitlab.com" @@ -5347,11 +5167,7 @@ "sha": "048721d90c449b244b7b4c53a9186b04330174ec", "message": "LFS object pointer.\n", "authored_date": "2015-12-07T11:54:28.000+01:00", - "author_name": "Marin Jankovski", - "author_email": "maxlazio@gmail.com", "committed_date": "2015-12-07T11:54:28.000+01:00", - "committer_name": "Marin Jankovski", - "committer_email": "maxlazio@gmail.com", "commit_author": { "name": "Marin Jankovski", "email": "maxlazio@gmail.com" @@ -5367,11 +5183,7 @@ "sha": "5f923865dde3436854e9ceb9cdb7815618d4e849", "message": "GitLab currently doesn't support patches that involve a merge commit: add a commit here\n", "authored_date": "2015-11-13T16:27:12.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T16:27:12.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -5387,11 +5199,7 @@ "sha": "d2d430676773caa88cdaf7c55944073b2fd5561a", "message": "Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5", "authored_date": "2015-11-13T08:50:17.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T08:50:17.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -5407,11 +5215,7 @@ "sha": "2ea1f3dec713d940208fb5ce4a38765ecb5d3f73", "message": "Add GitLab SVG\n", "authored_date": "2015-11-13T08:39:43.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T08:39:43.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -5427,11 +5231,7 @@ "sha": "59e29889be61e6e0e5e223bfa9ac2721d31605b8", "message": "Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4", "authored_date": "2015-11-13T07:21:40.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T07:21:40.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -5447,11 +5247,7 @@ "sha": "66eceea0db202bb39c4e445e8ca28689645366c5", "message": "add spaces in whitespace file\n", "authored_date": "2015-11-13T06:01:27.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T06:01:27.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -5467,11 +5263,7 @@ "sha": "08f22f255f082689c0d7d39d19205085311542bc", "message": "remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n", "authored_date": "2015-11-13T06:00:16.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T06:00:16.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -5487,11 +5279,7 @@ "sha": "19e2e9b4ef76b422ce1154af39a91323ccc57434", "message": "Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3", "authored_date": "2015-11-13T05:23:14.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T05:23:14.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -5507,11 +5295,7 @@ "sha": "c642fe9b8b9f28f9225d7ea953fe14e74748d53b", "message": "add whitespace in empty\n", "authored_date": "2015-11-13T05:08:45.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T05:08:45.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -5527,11 +5311,7 @@ "sha": "9a944d90955aaf45f6d0c88f30e27f8d2c41cec0", "message": "add empty file\n", "authored_date": "2015-11-13T05:08:04.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T05:08:04.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -5547,11 +5327,7 @@ "sha": "c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd", "message": "Add ISO-8859 test file\n", "authored_date": "2015-08-25T17:53:12.000+02:00", - "author_name": "Stan Hu", - "author_email": "stanhu@packetzoom.com", "committed_date": "2015-08-25T17:53:12.000+02:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@packetzoom.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@packetzoom.com" @@ -6119,11 +5895,7 @@ "sha": "f998ac87ac9244f15e9c15109a6f4e62a54b779d", "message": "fixes #10\n", "authored_date": "2016-01-19T14:43:23.000+01:00", - "author_name": "James Lopez", - "author_email": "james@jameslopez.es", "committed_date": "2016-01-19T14:43:23.000+01:00", - "committer_name": "James Lopez", - "committer_email": "james@jameslopez.es", "commit_author": { "name": "James Lopez", "email": "james@jameslopez.es" @@ -6139,11 +5911,7 @@ "sha": "be93687618e4b132087f430a4d8fc3a609c9b77c", "message": "Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6", "authored_date": "2015-12-07T12:52:12.000+01:00", - "author_name": "Marin Jankovski", - "author_email": "marin@gitlab.com", "committed_date": "2015-12-07T12:52:12.000+01:00", - "committer_name": "Marin Jankovski", - "committer_email": "marin@gitlab.com", "commit_author": { "name": "Marin Jankovski", "email": "marin@gitlab.com" @@ -6159,11 +5927,7 @@ "sha": "048721d90c449b244b7b4c53a9186b04330174ec", "message": "LFS object pointer.\n", "authored_date": "2015-12-07T11:54:28.000+01:00", - "author_name": "Marin Jankovski", - "author_email": "maxlazio@gmail.com", "committed_date": "2015-12-07T11:54:28.000+01:00", - "committer_name": "Marin Jankovski", - "committer_email": "maxlazio@gmail.com", "commit_author": { "name": "Marin Jankovski", "email": "maxlazio@gmail.com" @@ -6179,11 +5943,7 @@ "sha": "5f923865dde3436854e9ceb9cdb7815618d4e849", "message": "GitLab currently doesn't support patches that involve a merge commit: add a commit here\n", "authored_date": "2015-11-13T16:27:12.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T16:27:12.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -6199,11 +5959,7 @@ "sha": "d2d430676773caa88cdaf7c55944073b2fd5561a", "message": "Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5", "authored_date": "2015-11-13T08:50:17.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T08:50:17.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -6219,11 +5975,7 @@ "sha": "2ea1f3dec713d940208fb5ce4a38765ecb5d3f73", "message": "Add GitLab SVG\n", "authored_date": "2015-11-13T08:39:43.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T08:39:43.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -6239,11 +5991,7 @@ "sha": "59e29889be61e6e0e5e223bfa9ac2721d31605b8", "message": "Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4", "authored_date": "2015-11-13T07:21:40.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T07:21:40.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -6259,11 +6007,7 @@ "sha": "66eceea0db202bb39c4e445e8ca28689645366c5", "message": "add spaces in whitespace file\n", "authored_date": "2015-11-13T06:01:27.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T06:01:27.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -6279,11 +6023,7 @@ "sha": "08f22f255f082689c0d7d39d19205085311542bc", "message": "remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n", "authored_date": "2015-11-13T06:00:16.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T06:00:16.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -6299,11 +6039,7 @@ "sha": "19e2e9b4ef76b422ce1154af39a91323ccc57434", "message": "Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3", "authored_date": "2015-11-13T05:23:14.000+01:00", - "author_name": "Stan Hu", - "author_email": "stanhu@gmail.com", "committed_date": "2015-11-13T05:23:14.000+01:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@gmail.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@gmail.com" @@ -6319,11 +6055,7 @@ "sha": "c642fe9b8b9f28f9225d7ea953fe14e74748d53b", "message": "add whitespace in empty\n", "authored_date": "2015-11-13T05:08:45.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T05:08:45.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -6339,11 +6071,7 @@ "sha": "9a944d90955aaf45f6d0c88f30e27f8d2c41cec0", "message": "add empty file\n", "authored_date": "2015-11-13T05:08:04.000+01:00", - "author_name": "윤민식", - "author_email": "minsik.yoon@samsung.com", "committed_date": "2015-11-13T05:08:04.000+01:00", - "committer_name": "윤민식", - "committer_email": "minsik.yoon@samsung.com", "commit_author": { "name": "윤민식", "email": "minsik.yoon@samsung.com" @@ -6359,11 +6087,7 @@ "sha": "c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd", "message": "Add ISO-8859 test file\n", "authored_date": "2015-08-25T17:53:12.000+02:00", - "author_name": "Stan Hu", - "author_email": "stanhu@packetzoom.com", "committed_date": "2015-08-25T17:53:12.000+02:00", - "committer_name": "Stan Hu", - "committer_email": "stanhu@packetzoom.com", "commit_author": { "name": "Stan Hu", "email": "stanhu@packetzoom.com" @@ -6379,11 +6103,7 @@ "sha": "e56497bb5f03a90a51293fc6d516788730953899", "message": "Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n", "authored_date": "2015-01-10T22:23:29.000+01:00", - "author_name": "Sytse Sijbrandij", - "author_email": "sytse@gitlab.com", "committed_date": "2015-01-10T22:23:29.000+01:00", - "committer_name": "Sytse Sijbrandij", - "committer_email": "sytse@gitlab.com", "commit_author": { "name": "Sytse Sijbrandij", "email": "sytse@gitlab.com" @@ -6399,11 +6119,7 @@ "sha": "4cd80ccab63c82b4bad16faa5193fbd2aa06df40", "message": "add directory structure for tree_helper spec\n", "authored_date": "2015-01-10T21:28:18.000+01:00", - "author_name": "marmis85", - "author_email": "marmis85@gmail.com", "committed_date": "2015-01-10T21:28:18.000+01:00", - "committer_name": "marmis85", - "committer_email": "marmis85@gmail.com", "commit_author": { "name": "marmis85", "email": "marmis85@gmail.com" @@ -6419,11 +6135,7 @@ "sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e", "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T10:01:38.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T10:01:38.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -6439,11 +6151,7 @@ "sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d", "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:57:31.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:57:31.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -6459,11 +6167,7 @@ "sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9", "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:54:21.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:54:21.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -6479,11 +6183,7 @@ "sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08", "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:49:50.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:49:50.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -6499,11 +6199,7 @@ "sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8", "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:48:32.000+01:00", - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:48:32.000+01:00", - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" @@ -6952,11 +6648,7 @@ "sha": "a4e5dfebf42e34596526acb8611bc7ed80e4eb3f", "message": "fixes #10\n", "authored_date": "2016-01-19T15:44:02.000+01:00", - "author_name": "James Lopez", - "author_email": "james@jameslopez.es", "committed_date": "2016-01-19T15:44:02.000+01:00", - "committer_name": "James Lopez", - "committer_email": "james@jameslopez.es", "commit_author": { "name": "James Lopez", "email": "james@jameslopez.es" diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/merge_requests.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/merge_requests.ndjson index 741360c0b8e..16e45509a1b 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/merge_requests.ndjson +++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/merge_requests.ndjson @@ -1,9 +1,9 @@ -{"id":27,"target_branch":"feature","source_branch":"feature_conflict","source_project_id":2147483547,"author_id":1,"assignee_id":null,"title":"MR1","created_at":"2016-06-14T15:02:36.568Z","updated_at":"2016-06-14T15:02:56.815Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":9,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"diff_head_sha":"HEAD","source_branch_sha":"ABCD","target_branch_sha":"DCBA","merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":true,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":669,"note":"added 3 commits\n\n
  • 16ea4e20...074a2a32 - 2 commits from branch master
  • ca223a02 - readme: fix typos
\n\n[Compare with previous version](/group/project/merge_requests/1/diffs?diff_id=1189&start_sha=16ea4e207fb258fe4e9c73185a725207c9a4f3e1)","noteable_type":"MergeRequest","author_id":26,"created_at":"2020-03-28T12:47:33.461Z","updated_at":"2020-03-28T12:47:33.461Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":null,"change_position":null,"resolved_by_push":null,"confidential":null,"type":null,"author":{"name":"User 4"},"award_emoji":[],"system_note_metadata":{"id":4789,"commit_count":3,"action":"commit","created_at":"2020-03-28T12:47:33.461Z","updated_at":"2020-03-28T12:47:33.461Z"},"events":[],"suggestions":[]},{"id":670,"note":"unmarked as a **Work In Progress**","noteable_type":"MergeRequest","author_id":26,"created_at":"2020-03-28T12:48:36.951Z","updated_at":"2020-03-28T12:48:36.951Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":null,"change_position":null,"resolved_by_push":null,"confidential":null,"type":null,"author":{"name":"User 4"},"award_emoji":[],"system_note_metadata":{"id":4790,"commit_count":null,"action":"title","created_at":"2020-03-28T12:48:36.951Z","updated_at":"2020-03-28T12:48:36.951Z"},"events":[],"suggestions":[]},{"id":671,"note":"Sit voluptatibus eveniet architecto quidem.","note_html":"

something else entirely

","cached_markdown_version":917504,"noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:56.632Z","updated_at":"2016-06-14T15:02:56.632Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[],"award_emoji":[{"id":1,"name":"tada","user_id":1,"awardable_type":"Note","awardable_id":1,"created_at":"2019-11-05T15:37:21.287Z","updated_at":"2019-11-05T15:37:21.287Z"}]},{"id":672,"note":"Odio maxime ratione voluptatibus sed.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:56.656Z","updated_at":"2016-06-14T15:02:56.656Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":673,"note":"Et deserunt et omnis nihil excepturi accusantium.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:56.679Z","updated_at":"2016-06-14T15:02:56.679Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":674,"note":"Saepe asperiores exercitationem non dignissimos laborum reiciendis et ipsum.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:56.700Z","updated_at":"2016-06-14T15:02:56.700Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[],"suggestions":[{"id":1,"note_id":674,"relative_order":0,"applied":false,"commit_id":null,"from_content":"Original line\n","to_content":"New line\n","lines_above":0,"lines_below":0,"outdated":false}]},{"id":675,"note":"Numquam est at dolor quo et sed eligendi similique.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:56.720Z","updated_at":"2016-06-14T15:02:56.720Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":676,"note":"Et perferendis aliquam sunt nisi labore delectus.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:56.742Z","updated_at":"2016-06-14T15:02:56.742Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":677,"note":"Aut ex rerum et in.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:56.791Z","updated_at":"2016-06-14T15:02:56.791Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":678,"note":"Dolor laborum earum ut exercitationem.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:56.814Z","updated_at":"2016-06-14T15:02:56.814Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"resource_label_events":[{"id":243,"action":"add","issue_id":null,"merge_request_id":27,"label_id":null,"user_id":1,"created_at":"2018-08-28T08:24:00.494Z"}],"merge_request_diff":{"id":27,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":27,"relative_order":0,"sha":"bb5206fee213d983da88c47f9cf4cc6caf9c66dc","message":"Feature conflict added\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-08-06T08:35:52.000+02:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-08-06T08:35:52.000+02:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":1,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T10:01:38.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T10:01:38.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":2,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:57:31.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:57:31.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":3,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:54:21.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:54:21.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":4,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:49:50.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:49:50.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":5,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:48:32.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:48:32.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":27,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":27,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":3,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":27,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/ruby/feature.rb\n@@ -0,0 +1,4 @@\n+# This file was changed in feature branch\n+# We put different code here to make merge conflict\n+class Conflict\n+end\n","new_path":"files/ruby/feature.rb","old_path":"files/ruby/feature.rb","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":5,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":6,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":8,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":27,"created_at":"2016-06-14T15:02:36.572Z","updated_at":"2016-06-14T15:02:36.658Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"9"},"events":[{"id":221,"target_type":"MergeRequest","target_id":27,"project_id":36,"created_at":"2016-06-14T15:02:36.703Z","updated_at":"2016-06-14T15:02:36.703Z","action":1,"author_id":1},{"id":187,"target_type":"MergeRequest","target_id":27,"project_id":5,"created_at":"2016-06-14T15:02:36.703Z","updated_at":"2016-06-14T15:02:36.703Z","action":1,"author_id":1}],"approvals_before_merge":1,"award_emoji":[{"id":1,"name":"thumbsup","user_id":1,"awardable_type":"MergeRequest","awardable_id":27,"created_at":"2020-01-07T11:21:21.235Z","updated_at":"2020-01-07T11:21:21.235Z"},{"id":2,"name":"drum","user_id":1,"awardable_type":"MergeRequest","awardable_id":27,"created_at":"2020-01-07T11:21:21.235Z","updated_at":"2020-01-07T11:21:21.235Z"}]} -{"id":26,"target_branch":"master","source_branch":"feature","source_project_id":4,"author_id":1,"assignee_id":null,"title":"MR2","created_at":"2016-06-14T15:02:36.418Z","updated_at":"2016-06-14T15:02:57.013Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":8,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":679,"note":"Qui rerum totam nisi est.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:56.848Z","updated_at":"2016-06-14T15:02:56.848Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":680,"note":"Pariatur magni corrupti consequatur debitis minima error beatae voluptatem.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:56.871Z","updated_at":"2016-06-14T15:02:56.871Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":681,"note":"Qui quis ut modi eos rerum ratione.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:56.895Z","updated_at":"2016-06-14T15:02:56.895Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":682,"note":"Illum quidem expedita mollitia fugit.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:56.918Z","updated_at":"2016-06-14T15:02:56.918Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":683,"note":"Consectetur voluptate sit sint possimus veritatis quod.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:56.942Z","updated_at":"2016-06-14T15:02:56.942Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":684,"note":"Natus libero quibusdam rem assumenda deleniti accusamus sed earum.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:56.966Z","updated_at":"2016-06-14T15:02:56.966Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":685,"note":"Tenetur autem nihil rerum odit.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:56.989Z","updated_at":"2016-06-14T15:02:56.989Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":686,"note":"Quia maiores et odio sed.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:57.012Z","updated_at":"2016-06-14T15:02:57.012Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":26,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":26,"sha":"0b4bc9a49b562e85de7cc9e834518ea6828729b9","relative_order":0,"message":"Feature added\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:26:01.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:26:01.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":26,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/files/ruby/feature.rb\n@@ -0,0 +1,5 @@\n+class Feature\n+ def foo\n+ puts 'bar'\n+ end\n+end\n","new_path":"files/ruby/feature.rb","old_path":"files/ruby/feature.rb","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":26,"created_at":"2016-06-14T15:02:36.421Z","updated_at":"2016-06-14T15:02:36.474Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"1"},"events":[{"id":222,"target_type":"MergeRequest","target_id":26,"project_id":36,"created_at":"2016-06-14T15:02:36.496Z","updated_at":"2016-06-14T15:02:36.496Z","action":1,"author_id":1},{"id":186,"target_type":"MergeRequest","target_id":26,"project_id":5,"created_at":"2016-06-14T15:02:36.496Z","updated_at":"2016-06-14T15:02:36.496Z","action":1,"author_id":1}]} -{"id":15,"target_branch":"test-7","source_branch":"test-1","source_project_id":5,"author_id":22,"assignee_id":16,"title":"Qui accusantium et inventore facilis doloribus occaecati officiis.","created_at":"2016-06-14T15:02:25.168Z","updated_at":"2016-06-14T15:02:59.521Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":7,"description":"Et commodi deserunt aspernatur vero rerum. Ut non dolorum alias in odit est libero. Voluptatibus eos in et vitae repudiandae facilis ex mollitia.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":777,"note":"Pariatur voluptas placeat aspernatur culpa suscipit soluta.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.348Z","updated_at":"2016-06-14T15:02:59.348Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":778,"note":"Alias et iure mollitia suscipit molestiae voluptatum nostrum asperiores.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.372Z","updated_at":"2016-06-14T15:02:59.372Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":779,"note":"Laudantium qui eum qui sunt.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.395Z","updated_at":"2016-06-14T15:02:59.395Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":780,"note":"Quas rem est iusto ut delectus fugiat recusandae mollitia.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.418Z","updated_at":"2016-06-14T15:02:59.418Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":781,"note":"Repellendus ab et qui nesciunt.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.444Z","updated_at":"2016-06-14T15:02:59.444Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":782,"note":"Non possimus voluptatum odio qui ut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.469Z","updated_at":"2016-06-14T15:02:59.469Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":783,"note":"Dolores repellendus eum ducimus quam ab dolorem quia.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.494Z","updated_at":"2016-06-14T15:02:59.494Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":784,"note":"Facilis dolorem aut corrupti id ratione occaecati.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.520Z","updated_at":"2016-06-14T15:02:59.520Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":15,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":15,"relative_order":0,"sha":"94b8d581c48d894b86661718582fecbc5e3ed2eb","message":"fixes #10\n","authored_date":"2016-01-19T13:22:56.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T13:22:56.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}}],"merge_request_diff_files":[{"merge_request_diff_id":15,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":15,"created_at":"2016-06-14T15:02:25.171Z","updated_at":"2016-06-14T15:02:25.230Z","base_commit_sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","real_size":"1"},"events":[{"id":223,"target_type":"MergeRequest","target_id":15,"project_id":36,"created_at":"2016-06-14T15:02:25.262Z","updated_at":"2016-06-14T15:02:25.262Z","action":1,"author_id":1},{"id":175,"target_type":"MergeRequest","target_id":15,"project_id":5,"created_at":"2016-06-14T15:02:25.262Z","updated_at":"2016-06-14T15:02:25.262Z","action":1,"author_id":22}]} -{"id":14,"target_branch":"fix","source_branch":"test-3","source_project_id":5,"author_id":20,"assignee_id":20,"title":"In voluptas aut sequi voluptatem ullam vel corporis illum consequatur.","created_at":"2016-06-14T15:02:24.760Z","updated_at":"2016-06-14T15:02:59.749Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":6,"description":"Dicta magnam non voluptates nam dignissimos nostrum deserunt. Dolorum et suscipit iure quae doloremque. Necessitatibus saepe aut labore sed.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":785,"note":"Atque cupiditate necessitatibus deserunt minus natus odit.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.559Z","updated_at":"2016-06-14T15:02:59.559Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":786,"note":"Non dolorem provident mollitia nesciunt optio ex eveniet.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.587Z","updated_at":"2016-06-14T15:02:59.587Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":787,"note":"Similique officia nemo quasi commodi accusantium quae qui.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.621Z","updated_at":"2016-06-14T15:02:59.621Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":788,"note":"Et est et alias ad dolor qui.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.650Z","updated_at":"2016-06-14T15:02:59.650Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":789,"note":"Numquam temporibus ratione voluptatibus aliquid.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.675Z","updated_at":"2016-06-14T15:02:59.675Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":790,"note":"Ut ex aliquam consectetur perferendis est hic aut quia.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.703Z","updated_at":"2016-06-14T15:02:59.703Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":791,"note":"Esse eos quam quaerat aut ut asperiores officiis.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.726Z","updated_at":"2016-06-14T15:02:59.726Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":792,"note":"Sint facilis accusantium iure blanditiis.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.748Z","updated_at":"2016-06-14T15:02:59.748Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":14,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":14,"relative_order":0,"sha":"ddd4ff416a931589c695eb4f5b23f844426f6928","message":"fixes #10\n","authored_date":"2016-01-19T14:14:43.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T14:14:43.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}},{"merge_request_diff_id":14,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","author_name":"Marin Jankovski","author_email":"marin@gitlab.com","committed_date":"2015-12-07T12:52:12.000+01:00","committer_name":"Marin Jankovski","committer_email":"marin@gitlab.com","commit_author":{"name":"Marin Jankovski","email":"marin@gitlab.com"},"committer":{"name":"Marin Jankovski","email":"marin@gitlab.com"}},{"merge_request_diff_id":14,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","author_name":"Marin Jankovski","author_email":"maxlazio@gmail.com","committed_date":"2015-12-07T11:54:28.000+01:00","committer_name":"Marin Jankovski","committer_email":"maxlazio@gmail.com","commit_author":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"},"committer":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"}},{"merge_request_diff_id":14,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T16:27:12.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:50:17.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:39:43.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T07:21:40.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:01:27.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":14,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:00:16.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":14,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T05:23:14.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:45.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":14,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:04.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":14,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","author_name":"Stan Hu","author_email":"stanhu@packetzoom.com","committed_date":"2015-08-25T17:53:12.000+02:00","committer_name":"Stan Hu","committer_email":"stanhu@packetzoom.com","commit_author":{"name":"Stan Hu","email":"stanhu@packetzoom.com"},"committer":{"name":"Stan Hu","email":"stanhu@packetzoom.com"}},{"merge_request_diff_id":14,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","author_name":"Sytse Sijbrandij","author_email":"sytse@gitlab.com","committed_date":"2015-01-10T22:23:29.000+01:00","committer_name":"Sytse Sijbrandij","committer_email":"sytse@gitlab.com","commit_author":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"},"committer":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"}},{"merge_request_diff_id":14,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","author_name":"marmis85","author_email":"marmis85@gmail.com","committed_date":"2015-01-10T21:28:18.000+01:00","committer_name":"marmis85","committer_email":"marmis85@gmail.com","commit_author":{"name":"marmis85","email":"marmis85@gmail.com"},"committer":{"name":"marmis85","email":"marmis85@gmail.com"}},{"merge_request_diff_id":14,"relative_order":15,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T10:01:38.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T10:01:38.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":14,"relative_order":16,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:57:31.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:57:31.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":14,"relative_order":17,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:54:21.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:54:21.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":14,"relative_order":18,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:49:50.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:49:50.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":14,"relative_order":19,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:48:32.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:48:32.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":14,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":14,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":3,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":5,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":14,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":8,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":9,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":10,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":11,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":12,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":13,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":14,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":14,"created_at":"2016-06-14T15:02:24.770Z","updated_at":"2016-06-14T15:02:25.007Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"15"},"events":[{"id":224,"target_type":"MergeRequest","target_id":14,"project_id":36,"created_at":"2016-06-14T15:02:25.113Z","updated_at":"2016-06-14T15:02:25.113Z","action":1,"author_id":1},{"id":174,"target_type":"MergeRequest","target_id":14,"project_id":5,"created_at":"2016-06-14T15:02:25.113Z","updated_at":"2016-06-14T15:02:25.113Z","action":1,"author_id":20}]} -{"id":13,"target_branch":"improve/awesome","source_branch":"test-8","source_project_id":5,"author_id":16,"assignee_id":25,"title":"Voluptates consequatur eius nemo amet libero animi illum delectus tempore.","created_at":"2016-06-14T15:02:24.415Z","updated_at":"2016-06-14T15:02:59.958Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":5,"description":"Est eaque quasi qui qui. Similique voluptatem impedit iusto ratione reprehenderit. Itaque est illum ut nulla aut.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":793,"note":"In illum maxime aperiam nulla est aspernatur.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.782Z","updated_at":"2016-06-14T15:02:59.782Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[{"merge_request_diff_id":14,"id":529,"target_type":"Note","target_id":793,"project_id":4,"created_at":"2016-07-07T14:35:12.128Z","updated_at":"2016-07-07T14:35:12.128Z","action":6,"author_id":1}]},{"id":794,"note":"Enim quia perferendis cum distinctio tenetur optio voluptas veniam.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.807Z","updated_at":"2016-06-14T15:02:59.807Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":795,"note":"Dolor ad quia quis pariatur ducimus.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.831Z","updated_at":"2016-06-14T15:02:59.831Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":796,"note":"Et a odio voluptate aut.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.854Z","updated_at":"2016-06-14T15:02:59.854Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":797,"note":"Quis nihil temporibus voluptatum modi minima a ut.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.879Z","updated_at":"2016-06-14T15:02:59.879Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":798,"note":"Ut alias consequatur in nostrum.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.904Z","updated_at":"2016-06-14T15:02:59.904Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":799,"note":"Voluptatibus aperiam assumenda et neque sint libero.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.926Z","updated_at":"2016-06-14T15:02:59.926Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":800,"note":"Veritatis voluptatem dolor dolores magni quo ut ipsa fuga.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.956Z","updated_at":"2016-06-14T15:02:59.956Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":13,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":13,"relative_order":0,"sha":"0bfedc29d30280c7e8564e19f654584b459e5868","message":"fixes #10\n","authored_date":"2016-01-19T15:25:23.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T15:25:23.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}},{"merge_request_diff_id":13,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","author_name":"Marin Jankovski","author_email":"marin@gitlab.com","committed_date":"2015-12-07T12:52:12.000+01:00","committer_name":"Marin Jankovski","committer_email":"marin@gitlab.com","commit_author":{"name":"Marin Jankovski","email":"marin@gitlab.com"},"committer":{"name":"Marin Jankovski","email":"marin@gitlab.com"}},{"merge_request_diff_id":13,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","author_name":"Marin Jankovski","author_email":"maxlazio@gmail.com","committed_date":"2015-12-07T11:54:28.000+01:00","committer_name":"Marin Jankovski","committer_email":"maxlazio@gmail.com","commit_author":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"},"committer":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"}},{"merge_request_diff_id":13,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T16:27:12.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:50:17.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:39:43.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T07:21:40.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:01:27.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":13,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:00:16.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":13,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T05:23:14.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:45.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":13,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:04.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":13,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","author_name":"Stan Hu","author_email":"stanhu@packetzoom.com","committed_date":"2015-08-25T17:53:12.000+02:00","committer_name":"Stan Hu","committer_email":"stanhu@packetzoom.com","commit_author":{"name":"Stan Hu","email":"stanhu@packetzoom.com"},"committer":{"name":"Stan Hu","email":"stanhu@packetzoom.com"}},{"merge_request_diff_id":13,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","author_name":"Sytse Sijbrandij","author_email":"sytse@gitlab.com","committed_date":"2015-01-10T22:23:29.000+01:00","committer_name":"Sytse Sijbrandij","committer_email":"sytse@gitlab.com","commit_author":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"},"committer":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"}},{"merge_request_diff_id":13,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","author_name":"marmis85","author_email":"marmis85@gmail.com","committed_date":"2015-01-10T21:28:18.000+01:00","committer_name":"marmis85","committer_email":"marmis85@gmail.com","commit_author":{"name":"marmis85","email":"marmis85@gmail.com"},"committer":{"name":"marmis85","email":"marmis85@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":13,"relative_order":0,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":1,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":2,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":3,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":5,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":13,"created_at":"2016-06-14T15:02:24.420Z","updated_at":"2016-06-14T15:02:24.561Z","base_commit_sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","real_size":"7"},"events":[{"id":225,"target_type":"MergeRequest","target_id":13,"project_id":36,"created_at":"2016-06-14T15:02:24.636Z","updated_at":"2016-06-14T15:02:24.636Z","action":1,"author_id":16},{"id":173,"target_type":"MergeRequest","target_id":13,"project_id":5,"created_at":"2016-06-14T15:02:24.636Z","updated_at":"2016-06-14T15:02:24.636Z","action":1,"author_id":16}]} -{"id":12,"target_branch":"flatten-dirs","source_branch":"test-2","source_project_id":5,"author_id":1,"assignee_id":22,"title":"In a rerum harum nihil accusamus aut quia nobis non.","created_at":"2016-06-14T15:02:24.000Z","updated_at":"2016-06-14T15:03:00.225Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":4,"description":"Nam magnam odit velit rerum. Sapiente dolore sunt saepe debitis. Culpa maiores ut ad dolores dolorem et.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":801,"note":"Nihil dicta molestias expedita atque.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.001Z","updated_at":"2016-06-14T15:03:00.001Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":802,"note":"Illum culpa voluptas enim accusantium deserunt.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.034Z","updated_at":"2016-06-14T15:03:00.034Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":803,"note":"Dicta esse aliquam laboriosam unde alias.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.065Z","updated_at":"2016-06-14T15:03:00.065Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":804,"note":"Dicta autem et sed molestiae ut quae.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.097Z","updated_at":"2016-06-14T15:03:00.097Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":805,"note":"Ut ut temporibus voluptas dolore quia velit.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.129Z","updated_at":"2016-06-14T15:03:00.129Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":806,"note":"Dolores similique sint pariatur error id quia fugit aut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.162Z","updated_at":"2016-06-14T15:03:00.162Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":807,"note":"Quisquam provident nihil aperiam voluptatem.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.193Z","updated_at":"2016-06-14T15:03:00.193Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":808,"note":"Similique quo vero expedita deserunt ipsam earum.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.224Z","updated_at":"2016-06-14T15:03:00.224Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":12,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":12,"relative_order":0,"sha":"97a0df9696e2aebf10c31b3016f40214e0e8f243","message":"fixes #10\n","authored_date":"2016-01-19T14:08:21.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T14:08:21.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}},{"merge_request_diff_id":12,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","author_name":"Marin Jankovski","author_email":"marin@gitlab.com","committed_date":"2015-12-07T12:52:12.000+01:00","committer_name":"Marin Jankovski","committer_email":"marin@gitlab.com","commit_author":{"name":"Marin Jankovski","email":"marin@gitlab.com"},"committer":{"name":"Marin Jankovski","email":"marin@gitlab.com"}},{"merge_request_diff_id":12,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","author_name":"Marin Jankovski","author_email":"maxlazio@gmail.com","committed_date":"2015-12-07T11:54:28.000+01:00","committer_name":"Marin Jankovski","committer_email":"maxlazio@gmail.com","commit_author":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"},"committer":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"}},{"merge_request_diff_id":12,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T16:27:12.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:50:17.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:39:43.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T07:21:40.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:01:27.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":12,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:00:16.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":12,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T05:23:14.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:45.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":12,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:04.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":12,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","author_name":"Stan Hu","author_email":"stanhu@packetzoom.com","committed_date":"2015-08-25T17:53:12.000+02:00","committer_name":"Stan Hu","committer_email":"stanhu@packetzoom.com","commit_author":{"name":"Stan Hu","email":"stanhu@packetzoom.com"},"committer":{"name":"Stan Hu","email":"stanhu@packetzoom.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":12,"relative_order":0,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":1,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":2,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":3,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":5,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":12,"created_at":"2016-06-14T15:02:24.006Z","updated_at":"2016-06-14T15:02:24.169Z","base_commit_sha":"e56497bb5f03a90a51293fc6d516788730953899","real_size":"6"},"events":[{"id":226,"target_type":"MergeRequest","target_id":12,"project_id":36,"created_at":"2016-06-14T15:02:24.253Z","updated_at":"2016-06-14T15:02:24.253Z","action":1,"author_id":1},{"id":172,"target_type":"MergeRequest","target_id":12,"project_id":5,"created_at":"2016-06-14T15:02:24.253Z","updated_at":"2016-06-14T15:02:24.253Z","action":1,"author_id":1}]} +{"id":27,"target_branch":"feature","source_branch":"feature_conflict","source_project_id":2147483547,"author_id":1,"assignee_id":null,"title":"MR1","created_at":"2016-06-14T15:02:36.568Z","updated_at":"2016-06-14T15:02:56.815Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":9,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"diff_head_sha":"HEAD","source_branch_sha":"ABCD","target_branch_sha":"DCBA","merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":true,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":669,"note":"added 3 commits\n\n
  • 16ea4e20...074a2a32 - 2 commits from branch master
  • ca223a02 - readme: fix typos
\n\n[Compare with previous version](/group/project/merge_requests/1/diffs?diff_id=1189&start_sha=16ea4e207fb258fe4e9c73185a725207c9a4f3e1)","noteable_type":"MergeRequest","author_id":26,"created_at":"2020-03-28T12:47:33.461Z","updated_at":"2020-03-28T12:47:33.461Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":null,"change_position":null,"resolved_by_push":null,"confidential":null,"type":null,"author":{"name":"User 4"},"award_emoji":[],"system_note_metadata":{"id":4789,"commit_count":3,"action":"commit","created_at":"2020-03-28T12:47:33.461Z","updated_at":"2020-03-28T12:47:33.461Z"},"events":[],"suggestions":[]},{"id":670,"note":"unmarked as a **Work In Progress**","noteable_type":"MergeRequest","author_id":26,"created_at":"2020-03-28T12:48:36.951Z","updated_at":"2020-03-28T12:48:36.951Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"system":true,"st_diff":null,"updated_by_id":null,"position":null,"original_position":null,"resolved_at":null,"resolved_by_id":null,"discussion_id":null,"change_position":null,"resolved_by_push":null,"confidential":null,"type":null,"author":{"name":"User 4"},"award_emoji":[],"system_note_metadata":{"id":4790,"commit_count":null,"action":"title","created_at":"2020-03-28T12:48:36.951Z","updated_at":"2020-03-28T12:48:36.951Z"},"events":[],"suggestions":[]},{"id":671,"note":"Sit voluptatibus eveniet architecto quidem.","note_html":"

something else entirely

","cached_markdown_version":917504,"noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:56.632Z","updated_at":"2016-06-14T15:02:56.632Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[],"award_emoji":[{"id":1,"name":"tada","user_id":1,"awardable_type":"Note","awardable_id":1,"created_at":"2019-11-05T15:37:21.287Z","updated_at":"2019-11-05T15:37:21.287Z"}]},{"id":672,"note":"Odio maxime ratione voluptatibus sed.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:56.656Z","updated_at":"2016-06-14T15:02:56.656Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":673,"note":"Et deserunt et omnis nihil excepturi accusantium.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:56.679Z","updated_at":"2016-06-14T15:02:56.679Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":674,"note":"Saepe asperiores exercitationem non dignissimos laborum reiciendis et ipsum.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:56.700Z","updated_at":"2016-06-14T15:02:56.700Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[],"suggestions":[{"id":1,"note_id":674,"relative_order":0,"applied":false,"commit_id":null,"from_content":"Original line\n","to_content":"New line\n","lines_above":0,"lines_below":0,"outdated":false}]},{"id":675,"note":"Numquam est at dolor quo et sed eligendi similique.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:56.720Z","updated_at":"2016-06-14T15:02:56.720Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":676,"note":"Et perferendis aliquam sunt nisi labore delectus.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:56.742Z","updated_at":"2016-06-14T15:02:56.742Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":677,"note":"Aut ex rerum et in.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:56.791Z","updated_at":"2016-06-14T15:02:56.791Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":678,"note":"Dolor laborum earum ut exercitationem.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:56.814Z","updated_at":"2016-06-14T15:02:56.814Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":27,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"resource_label_events":[{"id":243,"action":"add","issue_id":null,"merge_request_id":27,"label_id":null,"user_id":1,"created_at":"2018-08-28T08:24:00.494Z"}],"merge_request_diff":{"id":27,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":27,"relative_order":0,"sha":"bb5206fee213d983da88c47f9cf4cc6caf9c66dc","message":"Feature conflict added\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-08-06T08:35:52.000+02:00","committed_date":"2014-08-06T08:35:52.000+02:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":1,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T10:01:38.000+01:00","committed_date":"2014-02-27T10:01:38.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":2,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:57:31.000+01:00","committed_date":"2014-02-27T09:57:31.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":3,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:54:21.000+01:00","committed_date":"2014-02-27T09:54:21.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":4,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:49:50.000+01:00","committed_date":"2014-02-27T09:49:50.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":27,"relative_order":5,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:48:32.000+01:00","committed_date":"2014-02-27T09:48:32.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":27,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":27,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":3,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":27,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/ruby/feature.rb\n@@ -0,0 +1,4 @@\n+# This file was changed in feature branch\n+# We put different code here to make merge conflict\n+class Conflict\n+end\n","new_path":"files/ruby/feature.rb","old_path":"files/ruby/feature.rb","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":5,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":6,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":27,"relative_order":8,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":27,"created_at":"2016-06-14T15:02:36.572Z","updated_at":"2016-06-14T15:02:36.658Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"9"},"events":[{"id":221,"target_type":"MergeRequest","target_id":27,"project_id":36,"created_at":"2016-06-14T15:02:36.703Z","updated_at":"2016-06-14T15:02:36.703Z","action":1,"author_id":1},{"id":187,"target_type":"MergeRequest","target_id":27,"project_id":5,"created_at":"2016-06-14T15:02:36.703Z","updated_at":"2016-06-14T15:02:36.703Z","action":1,"author_id":1}],"approvals_before_merge":1,"award_emoji":[{"id":1,"name":"thumbsup","user_id":1,"awardable_type":"MergeRequest","awardable_id":27,"created_at":"2020-01-07T11:21:21.235Z","updated_at":"2020-01-07T11:21:21.235Z"},{"id":2,"name":"drum","user_id":1,"awardable_type":"MergeRequest","awardable_id":27,"created_at":"2020-01-07T11:21:21.235Z","updated_at":"2020-01-07T11:21:21.235Z"}]} +{"id":26,"target_branch":"master","source_branch":"feature","source_project_id":4,"author_id":1,"assignee_id":null,"title":"MR2","created_at":"2016-06-14T15:02:36.418Z","updated_at":"2016-06-14T15:02:57.013Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":8,"description":null,"position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":679,"note":"Qui rerum totam nisi est.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:56.848Z","updated_at":"2016-06-14T15:02:56.848Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":680,"note":"Pariatur magni corrupti consequatur debitis minima error beatae voluptatem.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:56.871Z","updated_at":"2016-06-14T15:02:56.871Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":681,"note":"Qui quis ut modi eos rerum ratione.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:56.895Z","updated_at":"2016-06-14T15:02:56.895Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":682,"note":"Illum quidem expedita mollitia fugit.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:56.918Z","updated_at":"2016-06-14T15:02:56.918Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":683,"note":"Consectetur voluptate sit sint possimus veritatis quod.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:56.942Z","updated_at":"2016-06-14T15:02:56.942Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":684,"note":"Natus libero quibusdam rem assumenda deleniti accusamus sed earum.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:56.966Z","updated_at":"2016-06-14T15:02:56.966Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":685,"note":"Tenetur autem nihil rerum odit.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:56.989Z","updated_at":"2016-06-14T15:02:56.989Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":686,"note":"Quia maiores et odio sed.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:57.012Z","updated_at":"2016-06-14T15:02:57.012Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":26,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":26,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":26,"sha":"0b4bc9a49b562e85de7cc9e834518ea6828729b9","relative_order":0,"message":"Feature added\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:26:01.000+01:00","committed_date":"2014-02-27T09:26:01.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":26,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/files/ruby/feature.rb\n@@ -0,0 +1,5 @@\n+class Feature\n+ def foo\n+ puts 'bar'\n+ end\n+end\n","new_path":"files/ruby/feature.rb","old_path":"files/ruby/feature.rb","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":26,"created_at":"2016-06-14T15:02:36.421Z","updated_at":"2016-06-14T15:02:36.474Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"1"},"events":[{"id":222,"target_type":"MergeRequest","target_id":26,"project_id":36,"created_at":"2016-06-14T15:02:36.496Z","updated_at":"2016-06-14T15:02:36.496Z","action":1,"author_id":1},{"id":186,"target_type":"MergeRequest","target_id":26,"project_id":5,"created_at":"2016-06-14T15:02:36.496Z","updated_at":"2016-06-14T15:02:36.496Z","action":1,"author_id":1}]} +{"id":15,"target_branch":"test-7","source_branch":"test-1","source_project_id":5,"author_id":22,"assignee_id":16,"title":"Qui accusantium et inventore facilis doloribus occaecati officiis.","created_at":"2016-06-14T15:02:25.168Z","updated_at":"2016-06-14T15:02:59.521Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":7,"description":"Et commodi deserunt aspernatur vero rerum. Ut non dolorum alias in odit est libero. Voluptatibus eos in et vitae repudiandae facilis ex mollitia.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":777,"note":"Pariatur voluptas placeat aspernatur culpa suscipit soluta.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.348Z","updated_at":"2016-06-14T15:02:59.348Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":778,"note":"Alias et iure mollitia suscipit molestiae voluptatum nostrum asperiores.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.372Z","updated_at":"2016-06-14T15:02:59.372Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":779,"note":"Laudantium qui eum qui sunt.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.395Z","updated_at":"2016-06-14T15:02:59.395Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":780,"note":"Quas rem est iusto ut delectus fugiat recusandae mollitia.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.418Z","updated_at":"2016-06-14T15:02:59.418Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":781,"note":"Repellendus ab et qui nesciunt.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.444Z","updated_at":"2016-06-14T15:02:59.444Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":782,"note":"Non possimus voluptatum odio qui ut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.469Z","updated_at":"2016-06-14T15:02:59.469Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":783,"note":"Dolores repellendus eum ducimus quam ab dolorem quia.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.494Z","updated_at":"2016-06-14T15:02:59.494Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":784,"note":"Facilis dolorem aut corrupti id ratione occaecati.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.520Z","updated_at":"2016-06-14T15:02:59.520Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":15,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":15,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":15,"relative_order":0,"sha":"94b8d581c48d894b86661718582fecbc5e3ed2eb","message":"fixes #10\n","authored_date":"2016-01-19T13:22:56.000+01:00","committed_date":"2016-01-19T13:22:56.000+01:00","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}}],"merge_request_diff_files":[{"merge_request_diff_id":15,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":15,"created_at":"2016-06-14T15:02:25.171Z","updated_at":"2016-06-14T15:02:25.230Z","base_commit_sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","real_size":"1"},"events":[{"id":223,"target_type":"MergeRequest","target_id":15,"project_id":36,"created_at":"2016-06-14T15:02:25.262Z","updated_at":"2016-06-14T15:02:25.262Z","action":1,"author_id":1},{"id":175,"target_type":"MergeRequest","target_id":15,"project_id":5,"created_at":"2016-06-14T15:02:25.262Z","updated_at":"2016-06-14T15:02:25.262Z","action":1,"author_id":22}]} +{"id":14,"target_branch":"fix","source_branch":"test-3","source_project_id":5,"author_id":20,"assignee_id":20,"title":"In voluptas aut sequi voluptatem ullam vel corporis illum consequatur.","created_at":"2016-06-14T15:02:24.760Z","updated_at":"2016-06-14T15:02:59.749Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":6,"description":"Dicta magnam non voluptates nam dignissimos nostrum deserunt. Dolorum et suscipit iure quae doloremque. Necessitatibus saepe aut labore sed.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":785,"note":"Atque cupiditate necessitatibus deserunt minus natus odit.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.559Z","updated_at":"2016-06-14T15:02:59.559Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":786,"note":"Non dolorem provident mollitia nesciunt optio ex eveniet.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.587Z","updated_at":"2016-06-14T15:02:59.587Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":787,"note":"Similique officia nemo quasi commodi accusantium quae qui.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.621Z","updated_at":"2016-06-14T15:02:59.621Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":788,"note":"Et est et alias ad dolor qui.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.650Z","updated_at":"2016-06-14T15:02:59.650Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":789,"note":"Numquam temporibus ratione voluptatibus aliquid.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.675Z","updated_at":"2016-06-14T15:02:59.675Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":790,"note":"Ut ex aliquam consectetur perferendis est hic aut quia.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.703Z","updated_at":"2016-06-14T15:02:59.703Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":791,"note":"Esse eos quam quaerat aut ut asperiores officiis.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.726Z","updated_at":"2016-06-14T15:02:59.726Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":792,"note":"Sint facilis accusantium iure blanditiis.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.748Z","updated_at":"2016-06-14T15:02:59.748Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":14,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":14,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":14,"relative_order":0,"sha":"ddd4ff416a931589c695eb4f5b23f844426f6928","message":"fixes #10\n","authored_date":"2016-01-19T14:14:43.000+01:00","committed_date":"2016-01-19T14:14:43.000+01:00","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}},{"merge_request_diff_id":14,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","committed_date":"2015-12-07T12:52:12.000+01:00","commit_author":{"name":"Marin Jankovski","email":"marin@gitlab.com"},"committer":{"name":"Marin Jankovski","email":"marin@gitlab.com"}},{"merge_request_diff_id":14,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","committed_date":"2015-12-07T11:54:28.000+01:00","commit_author":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"},"committer":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"}},{"merge_request_diff_id":14,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","committed_date":"2015-11-13T16:27:12.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","committed_date":"2015-11-13T08:50:17.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","committed_date":"2015-11-13T08:39:43.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","committed_date":"2015-11-13T07:21:40.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","committed_date":"2015-11-13T06:01:27.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":14,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","committed_date":"2015-11-13T06:00:16.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":14,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","committed_date":"2015-11-13T05:23:14.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":14,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","committed_date":"2015-11-13T05:08:45.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":14,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","committed_date":"2015-11-13T05:08:04.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":14,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","committed_date":"2015-08-25T17:53:12.000+02:00","commit_author":{"name":"Stan Hu","email":"stanhu@packetzoom.com"},"committer":{"name":"Stan Hu","email":"stanhu@packetzoom.com"}},{"merge_request_diff_id":14,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","committed_date":"2015-01-10T22:23:29.000+01:00","commit_author":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"},"committer":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"}},{"merge_request_diff_id":14,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","committed_date":"2015-01-10T21:28:18.000+01:00","commit_author":{"name":"marmis85","email":"marmis85@gmail.com"},"committer":{"name":"marmis85","email":"marmis85@gmail.com"}},{"merge_request_diff_id":14,"relative_order":15,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T10:01:38.000+01:00","committed_date":"2014-02-27T10:01:38.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":14,"relative_order":16,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:57:31.000+01:00","committed_date":"2014-02-27T09:57:31.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":14,"relative_order":17,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:54:21.000+01:00","committed_date":"2014-02-27T09:54:21.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":14,"relative_order":18,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:49:50.000+01:00","committed_date":"2014-02-27T09:49:50.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":14,"relative_order":19,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:48:32.000+01:00","committed_date":"2014-02-27T09:48:32.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":14,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":14,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":3,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":5,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":14,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":8,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":9,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":10,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":11,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":12,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":13,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":14,"relative_order":14,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":14,"created_at":"2016-06-14T15:02:24.770Z","updated_at":"2016-06-14T15:02:25.007Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"15"},"events":[{"id":224,"target_type":"MergeRequest","target_id":14,"project_id":36,"created_at":"2016-06-14T15:02:25.113Z","updated_at":"2016-06-14T15:02:25.113Z","action":1,"author_id":1},{"id":174,"target_type":"MergeRequest","target_id":14,"project_id":5,"created_at":"2016-06-14T15:02:25.113Z","updated_at":"2016-06-14T15:02:25.113Z","action":1,"author_id":20}]} +{"id":13,"target_branch":"improve/awesome","source_branch":"test-8","source_project_id":5,"author_id":16,"assignee_id":25,"title":"Voluptates consequatur eius nemo amet libero animi illum delectus tempore.","created_at":"2016-06-14T15:02:24.415Z","updated_at":"2016-06-14T15:02:59.958Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":5,"description":"Est eaque quasi qui qui. Similique voluptatem impedit iusto ratione reprehenderit. Itaque est illum ut nulla aut.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":793,"note":"In illum maxime aperiam nulla est aspernatur.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:02:59.782Z","updated_at":"2016-06-14T15:02:59.782Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[{"merge_request_diff_id":14,"id":529,"target_type":"Note","target_id":793,"project_id":4,"created_at":"2016-07-07T14:35:12.128Z","updated_at":"2016-07-07T14:35:12.128Z","action":6,"author_id":1}]},{"id":794,"note":"Enim quia perferendis cum distinctio tenetur optio voluptas veniam.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:02:59.807Z","updated_at":"2016-06-14T15:02:59.807Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":795,"note":"Dolor ad quia quis pariatur ducimus.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:02:59.831Z","updated_at":"2016-06-14T15:02:59.831Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":796,"note":"Et a odio voluptate aut.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:02:59.854Z","updated_at":"2016-06-14T15:02:59.854Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":797,"note":"Quis nihil temporibus voluptatum modi minima a ut.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:02:59.879Z","updated_at":"2016-06-14T15:02:59.879Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":798,"note":"Ut alias consequatur in nostrum.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:02:59.904Z","updated_at":"2016-06-14T15:02:59.904Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":799,"note":"Voluptatibus aperiam assumenda et neque sint libero.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:02:59.926Z","updated_at":"2016-06-14T15:02:59.926Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":800,"note":"Veritatis voluptatem dolor dolores magni quo ut ipsa fuga.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:02:59.956Z","updated_at":"2016-06-14T15:02:59.956Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":13,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":13,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":13,"relative_order":0,"sha":"0bfedc29d30280c7e8564e19f654584b459e5868","message":"fixes #10\n","authored_date":"2016-01-19T15:25:23.000+01:00","committed_date":"2016-01-19T15:25:23.000+01:00","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}},{"merge_request_diff_id":13,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","committed_date":"2015-12-07T12:52:12.000+01:00","commit_author":{"name":"Marin Jankovski","email":"marin@gitlab.com"},"committer":{"name":"Marin Jankovski","email":"marin@gitlab.com"}},{"merge_request_diff_id":13,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","committed_date":"2015-12-07T11:54:28.000+01:00","commit_author":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"},"committer":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"}},{"merge_request_diff_id":13,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","committed_date":"2015-11-13T16:27:12.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","committed_date":"2015-11-13T08:50:17.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","committed_date":"2015-11-13T08:39:43.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","committed_date":"2015-11-13T07:21:40.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","committed_date":"2015-11-13T06:01:27.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":13,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","committed_date":"2015-11-13T06:00:16.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":13,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","committed_date":"2015-11-13T05:23:14.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":13,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","committed_date":"2015-11-13T05:08:45.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":13,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","committed_date":"2015-11-13T05:08:04.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":13,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","committed_date":"2015-08-25T17:53:12.000+02:00","commit_author":{"name":"Stan Hu","email":"stanhu@packetzoom.com"},"committer":{"name":"Stan Hu","email":"stanhu@packetzoom.com"}},{"merge_request_diff_id":13,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","committed_date":"2015-01-10T22:23:29.000+01:00","commit_author":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"},"committer":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"}},{"merge_request_diff_id":13,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","committed_date":"2015-01-10T21:28:18.000+01:00","commit_author":{"name":"marmis85","email":"marmis85@gmail.com"},"committer":{"name":"marmis85","email":"marmis85@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":13,"relative_order":0,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":1,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":2,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":3,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":5,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":13,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":13,"created_at":"2016-06-14T15:02:24.420Z","updated_at":"2016-06-14T15:02:24.561Z","base_commit_sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","real_size":"7"},"events":[{"id":225,"target_type":"MergeRequest","target_id":13,"project_id":36,"created_at":"2016-06-14T15:02:24.636Z","updated_at":"2016-06-14T15:02:24.636Z","action":1,"author_id":16},{"id":173,"target_type":"MergeRequest","target_id":13,"project_id":5,"created_at":"2016-06-14T15:02:24.636Z","updated_at":"2016-06-14T15:02:24.636Z","action":1,"author_id":16}]} +{"id":12,"target_branch":"flatten-dirs","source_branch":"test-2","source_project_id":5,"author_id":1,"assignee_id":22,"title":"In a rerum harum nihil accusamus aut quia nobis non.","created_at":"2016-06-14T15:02:24.000Z","updated_at":"2016-06-14T15:03:00.225Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":4,"description":"Nam magnam odit velit rerum. Sapiente dolore sunt saepe debitis. Culpa maiores ut ad dolores dolorem et.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":801,"note":"Nihil dicta molestias expedita atque.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.001Z","updated_at":"2016-06-14T15:03:00.001Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":802,"note":"Illum culpa voluptas enim accusantium deserunt.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.034Z","updated_at":"2016-06-14T15:03:00.034Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":803,"note":"Dicta esse aliquam laboriosam unde alias.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.065Z","updated_at":"2016-06-14T15:03:00.065Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":804,"note":"Dicta autem et sed molestiae ut quae.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.097Z","updated_at":"2016-06-14T15:03:00.097Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":805,"note":"Ut ut temporibus voluptas dolore quia velit.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.129Z","updated_at":"2016-06-14T15:03:00.129Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":806,"note":"Dolores similique sint pariatur error id quia fugit aut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.162Z","updated_at":"2016-06-14T15:03:00.162Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":807,"note":"Quisquam provident nihil aperiam voluptatem.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.193Z","updated_at":"2016-06-14T15:03:00.193Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":808,"note":"Similique quo vero expedita deserunt ipsam earum.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.224Z","updated_at":"2016-06-14T15:03:00.224Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":12,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":12,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":12,"relative_order":0,"sha":"97a0df9696e2aebf10c31b3016f40214e0e8f243","message":"fixes #10\n","authored_date":"2016-01-19T14:08:21.000+01:00","committed_date":"2016-01-19T14:08:21.000+01:00","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}},{"merge_request_diff_id":12,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","committed_date":"2015-12-07T12:52:12.000+01:00","commit_author":{"name":"Marin Jankovski","email":"marin@gitlab.com"},"committer":{"name":"Marin Jankovski","email":"marin@gitlab.com"}},{"merge_request_diff_id":12,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","committed_date":"2015-12-07T11:54:28.000+01:00","commit_author":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"},"committer":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"}},{"merge_request_diff_id":12,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","committed_date":"2015-11-13T16:27:12.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","committed_date":"2015-11-13T08:50:17.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","committed_date":"2015-11-13T08:39:43.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","committed_date":"2015-11-13T07:21:40.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","committed_date":"2015-11-13T06:01:27.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":12,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","committed_date":"2015-11-13T06:00:16.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":12,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","committed_date":"2015-11-13T05:23:14.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":12,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","committed_date":"2015-11-13T05:08:45.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":12,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","committed_date":"2015-11-13T05:08:04.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":12,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","committed_date":"2015-08-25T17:53:12.000+02:00","commit_author":{"name":"Stan Hu","email":"stanhu@packetzoom.com"},"committer":{"name":"Stan Hu","email":"stanhu@packetzoom.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":12,"relative_order":0,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":1,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":2,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":3,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":12,"relative_order":5,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":12,"created_at":"2016-06-14T15:02:24.006Z","updated_at":"2016-06-14T15:02:24.169Z","base_commit_sha":"e56497bb5f03a90a51293fc6d516788730953899","real_size":"6"},"events":[{"id":226,"target_type":"MergeRequest","target_id":12,"project_id":36,"created_at":"2016-06-14T15:02:24.253Z","updated_at":"2016-06-14T15:02:24.253Z","action":1,"author_id":1},{"id":172,"target_type":"MergeRequest","target_id":12,"project_id":5,"created_at":"2016-06-14T15:02:24.253Z","updated_at":"2016-06-14T15:02:24.253Z","action":1,"author_id":1}]} {"id":11,"target_branch":"test-15","source_branch":"'test'","source_project_id":5,"author_id":16,"assignee_id":16,"title":"Corporis provident similique perspiciatis dolores eos animi.","created_at":"2016-06-14T15:02:23.767Z","updated_at":"2016-06-14T15:03:00.475Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":3,"description":"Libero nesciunt mollitia quis odit eos vero quasi. Iure voluptatem ut sint pariatur voluptates ut aut. Laborum possimus unde illum ipsum eum.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":809,"note":"Omnis ratione laboriosam dolores qui.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.260Z","updated_at":"2016-06-14T15:03:00.260Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":810,"note":"Voluptas voluptates pariatur dolores maxime est voluptas.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.290Z","updated_at":"2016-06-14T15:03:00.290Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":811,"note":"Sit perspiciatis facilis ipsum consequatur.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.323Z","updated_at":"2016-06-14T15:03:00.323Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":812,"note":"Ut neque aliquam nam et est.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.349Z","updated_at":"2016-06-14T15:03:00.349Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":813,"note":"Et debitis rerum minima sit aut dolorem.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.374Z","updated_at":"2016-06-14T15:03:00.374Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":814,"note":"Ea nisi earum fugit iste aperiam consequatur.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.397Z","updated_at":"2016-06-14T15:03:00.397Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":815,"note":"Amet ratione consequatur laudantium rerum voluptas est nobis.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.450Z","updated_at":"2016-06-14T15:03:00.450Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":816,"note":"Ab ducimus cumque quia dolorem vitae sint beatae rerum.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.474Z","updated_at":"2016-06-14T15:03:00.474Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":11,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":11,"state":"empty","merge_request_diff_commits":[],"merge_request_diff_files":[],"merge_request_id":11,"created_at":"2016-06-14T15:02:23.772Z","updated_at":"2016-06-14T15:02:23.833Z","base_commit_sha":"e56497bb5f03a90a51293fc6d516788730953899","real_size":null},"events":[{"id":227,"target_type":"MergeRequest","target_id":11,"project_id":36,"created_at":"2016-06-14T15:02:23.865Z","updated_at":"2016-06-14T15:02:23.865Z","action":1,"author_id":16},{"id":171,"target_type":"MergeRequest","target_id":11,"project_id":5,"created_at":"2016-06-14T15:02:23.865Z","updated_at":"2016-06-14T15:02:23.865Z","action":1,"author_id":16}]} -{"id":10,"target_branch":"feature","source_branch":"test-5","source_project_id":5,"author_id":20,"assignee_id":25,"title":"Eligendi reprehenderit doloribus quia et sit id.","created_at":"2016-06-14T15:02:23.014Z","updated_at":"2016-06-14T15:03:00.685Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":2,"description":"Ut dolor quia aliquid dolore et nisi. Est minus suscipit enim quaerat sapiente consequatur rerum. Eveniet provident consequatur dolor accusantium reiciendis.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":817,"note":"Recusandae et voluptas enim qui et.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.510Z","updated_at":"2016-06-14T15:03:00.510Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":818,"note":"Asperiores dolorem rerum ipsum totam.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.538Z","updated_at":"2016-06-14T15:03:00.538Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":819,"note":"Qui quam et iure quasi provident cumque itaque sequi.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.562Z","updated_at":"2016-06-14T15:03:00.562Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":820,"note":"Sint accusantium aliquid iste qui iusto minus vel.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.585Z","updated_at":"2016-06-14T15:03:00.585Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":821,"note":"Dolor corrupti dolorem blanditiis voluptas.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.610Z","updated_at":"2016-06-14T15:03:00.610Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":822,"note":"Est perferendis assumenda aliquam aliquid sit ipsum ullam aut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.635Z","updated_at":"2016-06-14T15:03:00.635Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":823,"note":"Hic neque reiciendis quaerat maiores.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.659Z","updated_at":"2016-06-14T15:03:00.659Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":824,"note":"Sequi architecto doloribus ut vel autem.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.683Z","updated_at":"2016-06-14T15:03:00.683Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":10,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":10,"relative_order":0,"sha":"f998ac87ac9244f15e9c15109a6f4e62a54b779d","message":"fixes #10\n","authored_date":"2016-01-19T14:43:23.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T14:43:23.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}},{"merge_request_diff_id":10,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","author_name":"Marin Jankovski","author_email":"marin@gitlab.com","committed_date":"2015-12-07T12:52:12.000+01:00","committer_name":"Marin Jankovski","committer_email":"marin@gitlab.com","commit_author":{"name":"Marin Jankovski","email":"marin@gitlab.com"},"committer":{"name":"Marin Jankovski","email":"marin@gitlab.com"}},{"merge_request_diff_id":10,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","author_name":"Marin Jankovski","author_email":"maxlazio@gmail.com","committed_date":"2015-12-07T11:54:28.000+01:00","committer_name":"Marin Jankovski","committer_email":"maxlazio@gmail.com","commit_author":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"},"committer":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"}},{"merge_request_diff_id":10,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T16:27:12.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:50:17.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T08:39:43.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T07:21:40.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:01:27.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":10,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T06:00:16.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":10,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","author_name":"Stan Hu","author_email":"stanhu@gmail.com","committed_date":"2015-11-13T05:23:14.000+01:00","committer_name":"Stan Hu","committer_email":"stanhu@gmail.com","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:45.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":10,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","author_name":"윤민식","author_email":"minsik.yoon@samsung.com","committed_date":"2015-11-13T05:08:04.000+01:00","committer_name":"윤민식","committer_email":"minsik.yoon@samsung.com","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":10,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","author_name":"Stan Hu","author_email":"stanhu@packetzoom.com","committed_date":"2015-08-25T17:53:12.000+02:00","committer_name":"Stan Hu","committer_email":"stanhu@packetzoom.com","commit_author":{"name":"Stan Hu","email":"stanhu@packetzoom.com"},"committer":{"name":"Stan Hu","email":"stanhu@packetzoom.com"}},{"merge_request_diff_id":10,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","author_name":"Sytse Sijbrandij","author_email":"sytse@gitlab.com","committed_date":"2015-01-10T22:23:29.000+01:00","committer_name":"Sytse Sijbrandij","committer_email":"sytse@gitlab.com","commit_author":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"},"committer":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"}},{"merge_request_diff_id":10,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","author_name":"marmis85","author_email":"marmis85@gmail.com","committed_date":"2015-01-10T21:28:18.000+01:00","committer_name":"marmis85","committer_email":"marmis85@gmail.com","commit_author":{"name":"marmis85","email":"marmis85@gmail.com"},"committer":{"name":"marmis85","email":"marmis85@gmail.com"}},{"merge_request_diff_id":10,"relative_order":16,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T10:01:38.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T10:01:38.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":10,"relative_order":17,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:57:31.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:57:31.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":10,"relative_order":18,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:54:21.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:54:21.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":10,"relative_order":19,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:49:50.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:49:50.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":10,"relative_order":20,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:48:32.000+01:00","author_name":"Dmitriy Zaporozhets","author_email":"dmitriy.zaporozhets@gmail.com","committed_date":"2014-02-27T09:48:32.000+01:00","committer_name":"Dmitriy Zaporozhets","committer_email":"dmitriy.zaporozhets@gmail.com","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":10,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":10,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":3,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":5,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":10,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":8,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":9,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":10,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":11,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":12,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":13,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":14,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":10,"created_at":"2016-06-14T15:02:23.019Z","updated_at":"2016-06-14T15:02:23.493Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"15"},"events":[{"id":228,"target_type":"MergeRequest","target_id":10,"project_id":36,"created_at":"2016-06-14T15:02:23.660Z","updated_at":"2016-06-14T15:02:23.660Z","action":1,"author_id":1},{"id":170,"target_type":"MergeRequest","target_id":10,"project_id":5,"created_at":"2016-06-14T15:02:23.660Z","updated_at":"2016-06-14T15:02:23.660Z","action":1,"author_id":20}]} -{"id":9,"target_branch":"test-6","source_branch":"test-12","source_project_id":5,"author_id":16,"assignee_id":6,"title":"Et ipsam voluptas velit sequi illum ut.","created_at":"2016-06-14T15:02:22.825Z","updated_at":"2016-06-14T15:03:00.904Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":1,"description":"Eveniet nihil ratione veniam similique qui aut sapiente tempora. Sed praesentium iusto dignissimos possimus id repudiandae quo nihil. Qui doloremque autem et iure fugit.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":825,"note":"Aliquid voluptatem consequatur voluptas ex perspiciatis.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.722Z","updated_at":"2016-06-14T15:03:00.722Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":826,"note":"Itaque optio voluptatem praesentium voluptas.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.745Z","updated_at":"2016-06-14T15:03:00.745Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":827,"note":"Ut est corporis fuga asperiores delectus excepturi aperiam.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.771Z","updated_at":"2016-06-14T15:03:00.771Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":828,"note":"Similique ea dolore officiis temporibus.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.798Z","updated_at":"2016-06-14T15:03:00.798Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":829,"note":"Qui laudantium qui quae quis.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.828Z","updated_at":"2016-06-14T15:03:00.828Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":830,"note":"Et vel voluptas amet laborum qui soluta.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.850Z","updated_at":"2016-06-14T15:03:00.850Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":831,"note":"Enim ad consequuntur assumenda provident voluptatem similique deleniti.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.876Z","updated_at":"2016-06-14T15:03:00.876Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":832,"note":"Officiis sequi commodi pariatur totam fugiat voluptas corporis dignissimos.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.902Z","updated_at":"2016-06-14T15:03:00.902Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":9,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":9,"relative_order":0,"sha":"a4e5dfebf42e34596526acb8611bc7ed80e4eb3f","message":"fixes #10\n","authored_date":"2016-01-19T15:44:02.000+01:00","author_name":"James Lopez","author_email":"james@jameslopez.es","committed_date":"2016-01-19T15:44:02.000+01:00","committer_name":"James Lopez","committer_email":"james@jameslopez.es","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}}],"merge_request_diff_files":[{"merge_request_diff_id":9,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":9,"created_at":"2016-06-14T15:02:22.829Z","updated_at":"2016-06-14T15:02:22.900Z","base_commit_sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","real_size":"1"},"events":[{"id":229,"target_type":"MergeRequest","target_id":9,"project_id":36,"created_at":"2016-06-14T15:02:22.927Z","updated_at":"2016-06-14T15:02:22.927Z","action":1,"author_id":16},{"id":169,"target_type":"MergeRequest","target_id":9,"project_id":5,"created_at":"2016-06-14T15:02:22.927Z","updated_at":"2016-06-14T15:02:22.927Z","action":1,"author_id":16}]} +{"id":10,"target_branch":"feature","source_branch":"test-5","source_project_id":5,"author_id":20,"assignee_id":25,"title":"Eligendi reprehenderit doloribus quia et sit id.","created_at":"2016-06-14T15:02:23.014Z","updated_at":"2016-06-14T15:03:00.685Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":2,"description":"Ut dolor quia aliquid dolore et nisi. Est minus suscipit enim quaerat sapiente consequatur rerum. Eveniet provident consequatur dolor accusantium reiciendis.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":817,"note":"Recusandae et voluptas enim qui et.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.510Z","updated_at":"2016-06-14T15:03:00.510Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":818,"note":"Asperiores dolorem rerum ipsum totam.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.538Z","updated_at":"2016-06-14T15:03:00.538Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":819,"note":"Qui quam et iure quasi provident cumque itaque sequi.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.562Z","updated_at":"2016-06-14T15:03:00.562Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":820,"note":"Sint accusantium aliquid iste qui iusto minus vel.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.585Z","updated_at":"2016-06-14T15:03:00.585Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":821,"note":"Dolor corrupti dolorem blanditiis voluptas.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.610Z","updated_at":"2016-06-14T15:03:00.610Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":822,"note":"Est perferendis assumenda aliquam aliquid sit ipsum ullam aut.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.635Z","updated_at":"2016-06-14T15:03:00.635Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":823,"note":"Hic neque reiciendis quaerat maiores.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.659Z","updated_at":"2016-06-14T15:03:00.659Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":824,"note":"Sequi architecto doloribus ut vel autem.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.683Z","updated_at":"2016-06-14T15:03:00.683Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":10,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":10,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":10,"relative_order":0,"sha":"f998ac87ac9244f15e9c15109a6f4e62a54b779d","message":"fixes #10\n","authored_date":"2016-01-19T14:43:23.000+01:00","committed_date":"2016-01-19T14:43:23.000+01:00","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}},{"merge_request_diff_id":10,"relative_order":1,"sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","message":"Merge branch 'master' into 'master'\r\n\r\nLFS object pointer.\r\n\r\n\r\n\r\nSee merge request !6","authored_date":"2015-12-07T12:52:12.000+01:00","committed_date":"2015-12-07T12:52:12.000+01:00","commit_author":{"name":"Marin Jankovski","email":"marin@gitlab.com"},"committer":{"name":"Marin Jankovski","email":"marin@gitlab.com"}},{"merge_request_diff_id":10,"relative_order":2,"sha":"048721d90c449b244b7b4c53a9186b04330174ec","message":"LFS object pointer.\n","authored_date":"2015-12-07T11:54:28.000+01:00","committed_date":"2015-12-07T11:54:28.000+01:00","commit_author":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"},"committer":{"name":"Marin Jankovski","email":"maxlazio@gmail.com"}},{"merge_request_diff_id":10,"relative_order":3,"sha":"5f923865dde3436854e9ceb9cdb7815618d4e849","message":"GitLab currently doesn't support patches that involve a merge commit: add a commit here\n","authored_date":"2015-11-13T16:27:12.000+01:00","committed_date":"2015-11-13T16:27:12.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":4,"sha":"d2d430676773caa88cdaf7c55944073b2fd5561a","message":"Merge branch 'add-svg' into 'master'\r\n\r\nAdd GitLab SVG\r\n\r\nAdded to test preview of sanitized SVG images\r\n\r\nSee merge request !5","authored_date":"2015-11-13T08:50:17.000+01:00","committed_date":"2015-11-13T08:50:17.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":5,"sha":"2ea1f3dec713d940208fb5ce4a38765ecb5d3f73","message":"Add GitLab SVG\n","authored_date":"2015-11-13T08:39:43.000+01:00","committed_date":"2015-11-13T08:39:43.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":6,"sha":"59e29889be61e6e0e5e223bfa9ac2721d31605b8","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd whitespace test file\r\n\r\nSorry, I did a mistake.\r\nGit ignore empty files.\r\nSo I add a new whitespace test file.\r\n\r\nSee merge request !4","authored_date":"2015-11-13T07:21:40.000+01:00","committed_date":"2015-11-13T07:21:40.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":7,"sha":"66eceea0db202bb39c4e445e8ca28689645366c5","message":"add spaces in whitespace file\n","authored_date":"2015-11-13T06:01:27.000+01:00","committed_date":"2015-11-13T06:01:27.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":10,"relative_order":8,"sha":"08f22f255f082689c0d7d39d19205085311542bc","message":"remove empty file.(beacase git ignore empty file)\nadd whitespace test file.\n","authored_date":"2015-11-13T06:00:16.000+01:00","committed_date":"2015-11-13T06:00:16.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":10,"relative_order":9,"sha":"19e2e9b4ef76b422ce1154af39a91323ccc57434","message":"Merge branch 'whitespace' into 'master'\r\n\r\nadd spaces\r\n\r\nTo test this pull request.(https://github.com/gitlabhq/gitlabhq/pull/9757)\r\nJust add whitespaces.\r\n\r\nSee merge request !3","authored_date":"2015-11-13T05:23:14.000+01:00","committed_date":"2015-11-13T05:23:14.000+01:00","commit_author":{"name":"Stan Hu","email":"stanhu@gmail.com"},"committer":{"name":"Stan Hu","email":"stanhu@gmail.com"}},{"merge_request_diff_id":10,"relative_order":10,"sha":"c642fe9b8b9f28f9225d7ea953fe14e74748d53b","message":"add whitespace in empty\n","authored_date":"2015-11-13T05:08:45.000+01:00","committed_date":"2015-11-13T05:08:45.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":10,"relative_order":11,"sha":"9a944d90955aaf45f6d0c88f30e27f8d2c41cec0","message":"add empty file\n","authored_date":"2015-11-13T05:08:04.000+01:00","committed_date":"2015-11-13T05:08:04.000+01:00","commit_author":{"name":"윤민식","email":"minsik.yoon@samsung.com"},"committer":{"name":"윤민식","email":"minsik.yoon@samsung.com"}},{"merge_request_diff_id":10,"relative_order":12,"sha":"c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd","message":"Add ISO-8859 test file\n","authored_date":"2015-08-25T17:53:12.000+02:00","committed_date":"2015-08-25T17:53:12.000+02:00","commit_author":{"name":"Stan Hu","email":"stanhu@packetzoom.com"},"committer":{"name":"Stan Hu","email":"stanhu@packetzoom.com"}},{"merge_request_diff_id":10,"relative_order":13,"sha":"e56497bb5f03a90a51293fc6d516788730953899","message":"Merge branch 'tree_helper_spec' into 'master'\n\nAdd directory structure for tree_helper spec\n\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\n\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/275#note_732774)\n\nSee merge request !2\n","authored_date":"2015-01-10T22:23:29.000+01:00","committed_date":"2015-01-10T22:23:29.000+01:00","commit_author":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"},"committer":{"name":"Sytse Sijbrandij","email":"sytse@gitlab.com"}},{"merge_request_diff_id":10,"relative_order":14,"sha":"4cd80ccab63c82b4bad16faa5193fbd2aa06df40","message":"add directory structure for tree_helper spec\n","authored_date":"2015-01-10T21:28:18.000+01:00","committed_date":"2015-01-10T21:28:18.000+01:00","commit_author":{"name":"marmis85","email":"marmis85@gmail.com"},"committer":{"name":"marmis85","email":"marmis85@gmail.com"}},{"merge_request_diff_id":10,"relative_order":16,"sha":"5937ac0a7beb003549fc5fd26fc247adbce4a52e","message":"Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T10:01:38.000+01:00","committed_date":"2014-02-27T10:01:38.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":10,"relative_order":17,"sha":"570e7b2abdd848b95f2f578043fc23bd6f6fd24d","message":"Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:57:31.000+01:00","committed_date":"2014-02-27T09:57:31.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":10,"relative_order":18,"sha":"6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9","message":"More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:54:21.000+01:00","committed_date":"2014-02-27T09:54:21.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":10,"relative_order":19,"sha":"d14d6c0abdd253381df51a723d58691b2ee1ab08","message":"Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:49:50.000+01:00","committed_date":"2014-02-27T09:49:50.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}},{"merge_request_diff_id":10,"relative_order":20,"sha":"c1acaa58bbcbc3eafe538cb8274ba387047b69f8","message":"Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n","authored_date":"2014-02-27T09:48:32.000+01:00","committed_date":"2014-02-27T09:48:32.000+01:00","commit_author":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"},"committer":{"name":"Dmitriy Zaporozhets","email":"dmitriy.zaporozhets@gmail.com"}}],"merge_request_diff_files":[{"merge_request_diff_id":10,"relative_order":0,"utf8_diff":"Binary files a/.DS_Store and /dev/null differ\n","new_path":".DS_Store","old_path":".DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":10,"relative_order":1,"utf8_diff":"--- a/.gitignore\n+++ b/.gitignore\n@@ -17,3 +17,4 @@ rerun.txt\n pickle-email-*.html\n .project\n config/initializers/secret_token.rb\n+.DS_Store\n","new_path":".gitignore","old_path":".gitignore","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":2,"utf8_diff":"--- a/.gitmodules\n+++ b/.gitmodules\n@@ -1,3 +1,9 @@\n [submodule \"six\"]\n \tpath = six\n \turl = git://github.com/randx/six.git\n+[submodule \"gitlab-shell\"]\n+\tpath = gitlab-shell\n+\turl = https://github.com/gitlabhq/gitlab-shell.git\n+[submodule \"gitlab-grack\"]\n+\tpath = gitlab-grack\n+\turl = https://gitlab.com/gitlab-org/gitlab-grack.git\n","new_path":".gitmodules","old_path":".gitmodules","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":3,"utf8_diff":"--- a/CHANGELOG\n+++ b/CHANGELOG\n@@ -1,4 +1,6 @@\n-v 6.7.0\n+v6.8.0\n+\n+v6.7.0\n - Add support for Gemnasium as a Project Service (Olivier Gonzalez)\n - Add edit file button to MergeRequest diff\n - Public groups (Jason Hollingsworth)\n","new_path":"CHANGELOG","old_path":"CHANGELOG","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":4,"utf8_diff":"--- /dev/null\n+++ b/encoding/iso8859.txt\n@@ -0,0 +1 @@\n+Äü\n","new_path":"encoding/iso8859.txt","old_path":"encoding/iso8859.txt","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":5,"utf8_diff":"Binary files a/files/.DS_Store and /dev/null differ\n","new_path":"files/.DS_Store","old_path":"files/.DS_Store","a_mode":"100644","b_mode":"0","new_file":false,"renamed_file":false,"deleted_file":true,"too_large":false},{"merge_request_diff_id":10,"relative_order":6,"utf8_diff":"--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n","new_path":"files/images/wm.svg","old_path":"files/images/wm.svg","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":7,"utf8_diff":"--- /dev/null\n+++ b/files/lfs/lfs_object.iso\n@@ -0,0 +1,4 @@\n+version https://git-lfs.github.com/spec/v1\n+oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897\n+size 1575078\n+\n","new_path":"files/lfs/lfs_object.iso","old_path":"files/lfs/lfs_object.iso","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":8,"utf8_diff":"--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n","new_path":"files/ruby/popen.rb","old_path":"files/ruby/popen.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":9,"utf8_diff":"--- a/files/ruby/regex.rb\n+++ b/files/ruby/regex.rb\n@@ -19,14 +19,12 @@ module Gitlab\n end\n \n def archive_formats_regex\n- #|zip|tar| tar.gz | tar.bz2 |\n- /(zip|tar|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n+ /(zip|tar|7z|tar\\.gz|tgz|gz|tar\\.bz2|tbz|tbz2|tb2|bz2)/\n end\n \n def git_reference_regex\n # Valid git ref regex, see:\n # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html\n-\n %r{\n (?!\n (?# doesn't begins with)\n","new_path":"files/ruby/regex.rb","old_path":"files/ruby/regex.rb","a_mode":"100644","b_mode":"100644","new_file":false,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":10,"utf8_diff":"--- /dev/null\n+++ b/files/whitespace\n@@ -0,0 +1 @@\n+test \n","new_path":"files/whitespace","old_path":"files/whitespace","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":11,"utf8_diff":"--- /dev/null\n+++ b/foo/bar/.gitkeep\n","new_path":"foo/bar/.gitkeep","old_path":"foo/bar/.gitkeep","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":12,"utf8_diff":"--- /dev/null\n+++ b/gitlab-grack\n@@ -0,0 +1 @@\n+Subproject commit 645f6c4c82fd3f5e06f67134450a570b795e55a6\n","new_path":"gitlab-grack","old_path":"gitlab-grack","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":13,"utf8_diff":"--- /dev/null\n+++ b/gitlab-shell\n@@ -0,0 +1 @@\n+Subproject commit 79bceae69cb5750d6567b223597999bfa91cb3b9\n","new_path":"gitlab-shell","old_path":"gitlab-shell","a_mode":"0","b_mode":"160000","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false},{"merge_request_diff_id":10,"relative_order":14,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":10,"created_at":"2016-06-14T15:02:23.019Z","updated_at":"2016-06-14T15:02:23.493Z","base_commit_sha":"ae73cb07c9eeaf35924a10f713b364d32b2dd34f","real_size":"15"},"events":[{"id":228,"target_type":"MergeRequest","target_id":10,"project_id":36,"created_at":"2016-06-14T15:02:23.660Z","updated_at":"2016-06-14T15:02:23.660Z","action":1,"author_id":1},{"id":170,"target_type":"MergeRequest","target_id":10,"project_id":5,"created_at":"2016-06-14T15:02:23.660Z","updated_at":"2016-06-14T15:02:23.660Z","action":1,"author_id":20}]} +{"id":9,"target_branch":"test-6","source_branch":"test-12","source_project_id":5,"author_id":16,"assignee_id":6,"title":"Et ipsam voluptas velit sequi illum ut.","created_at":"2016-06-14T15:02:22.825Z","updated_at":"2016-06-14T15:03:00.904Z","state":"opened","merge_status":"unchecked","target_project_id":5,"iid":1,"description":"Eveniet nihil ratione veniam similique qui aut sapiente tempora. Sed praesentium iusto dignissimos possimus id repudiandae quo nihil. Qui doloremque autem et iure fugit.","position":0,"updated_by_id":null,"merge_error":null,"merge_params":{"force_remove_source_branch":null},"merge_when_pipeline_succeeds":false,"merge_user_id":null,"merge_commit_sha":null,"notes":[{"id":825,"note":"Aliquid voluptatem consequatur voluptas ex perspiciatis.","noteable_type":"MergeRequest","author_id":26,"created_at":"2016-06-14T15:03:00.722Z","updated_at":"2016-06-14T15:03:00.722Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":826,"note":"Itaque optio voluptatem praesentium voluptas.","noteable_type":"MergeRequest","author_id":25,"created_at":"2016-06-14T15:03:00.745Z","updated_at":"2016-06-14T15:03:00.745Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":827,"note":"Ut est corporis fuga asperiores delectus excepturi aperiam.","noteable_type":"MergeRequest","author_id":22,"created_at":"2016-06-14T15:03:00.771Z","updated_at":"2016-06-14T15:03:00.771Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":828,"note":"Similique ea dolore officiis temporibus.","noteable_type":"MergeRequest","author_id":20,"created_at":"2016-06-14T15:03:00.798Z","updated_at":"2016-06-14T15:03:00.798Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":829,"note":"Qui laudantium qui quae quis.","noteable_type":"MergeRequest","author_id":16,"created_at":"2016-06-14T15:03:00.828Z","updated_at":"2016-06-14T15:03:00.828Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":830,"note":"Et vel voluptas amet laborum qui soluta.","noteable_type":"MergeRequest","author_id":15,"created_at":"2016-06-14T15:03:00.850Z","updated_at":"2016-06-14T15:03:00.850Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":831,"note":"Enim ad consequuntur assumenda provident voluptatem similique deleniti.","noteable_type":"MergeRequest","author_id":6,"created_at":"2016-06-14T15:03:00.876Z","updated_at":"2016-06-14T15:03:00.876Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":832,"note":"Officiis sequi commodi pariatur totam fugiat voluptas corporis dignissimos.","noteable_type":"MergeRequest","author_id":1,"created_at":"2016-06-14T15:03:00.902Z","updated_at":"2016-06-14T15:03:00.902Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":9,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}],"merge_request_diff":{"id":9,"state":"collected","merge_request_diff_commits":[{"merge_request_diff_id":9,"relative_order":0,"sha":"a4e5dfebf42e34596526acb8611bc7ed80e4eb3f","message":"fixes #10\n","authored_date":"2016-01-19T15:44:02.000+01:00","committed_date":"2016-01-19T15:44:02.000+01:00","commit_author":{"name":"James Lopez","email":"james@jameslopez.es"},"committer":{"name":"James Lopez","email":"james@jameslopez.es"}}],"merge_request_diff_files":[{"merge_request_diff_id":9,"relative_order":0,"utf8_diff":"--- /dev/null\n+++ b/test\n","new_path":"test","old_path":"test","a_mode":"0","b_mode":"100644","new_file":true,"renamed_file":false,"deleted_file":false,"too_large":false}],"merge_request_id":9,"created_at":"2016-06-14T15:02:22.829Z","updated_at":"2016-06-14T15:02:22.900Z","base_commit_sha":"be93687618e4b132087f430a4d8fc3a609c9b77c","real_size":"1"},"events":[{"id":229,"target_type":"MergeRequest","target_id":9,"project_id":36,"created_at":"2016-06-14T15:02:22.927Z","updated_at":"2016-06-14T15:02:22.927Z","action":1,"author_id":16},{"id":169,"target_type":"MergeRequest","target_id":9,"project_id":5,"created_at":"2016-06-14T15:02:22.927Z","updated_at":"2016-06-14T15:02:22.927Z","action":1,"author_id":16}]} diff --git a/spec/fixtures/packages/npm/payload.json b/spec/fixtures/packages/npm/payload.json index 664aa636001..5ecb013b9bf 100644 --- a/spec/fixtures/packages/npm/payload.json +++ b/spec/fixtures/packages/npm/payload.json @@ -14,7 +14,8 @@ "express":"^4.16.4" }, "dist":{ - "shasum":"f572d396fae9206628714fb2ce00f72e94f2258f" + "shasum":"f572d396fae9206628714fb2ce00f72e94f2258f", + "tarball":"http://localhost/npm/package.tgz" } } }, diff --git a/spec/fixtures/packages/npm/payload_with_duplicated_packages.json b/spec/fixtures/packages/npm/payload_with_duplicated_packages.json index a6ea8760bd5..bc4a7b3f55a 100644 --- a/spec/fixtures/packages/npm/payload_with_duplicated_packages.json +++ b/spec/fixtures/packages/npm/payload_with_duplicated_packages.json @@ -28,7 +28,8 @@ "express":"^4.16.4" }, "dist":{ - "shasum":"f572d396fae9206628714fb2ce00f72e94f2258f" + "shasum":"f572d396fae9206628714fb2ce00f72e94f2258f", + "tarball":"http://localhost/npm/package.tgz" } } }, diff --git a/spec/fixtures/scripts/test_report.json b/spec/fixtures/scripts/test_report.json new file mode 100644 index 00000000000..29fd9a4bcb5 --- /dev/null +++ b/spec/fixtures/scripts/test_report.json @@ -0,0 +1,36 @@ +{ + "suites": [ + { + "name": "rspec unit pg12", + "total_time": 975.6635620000018, + "total_count": 3811, + "success_count": 3800, + "failed_count": 1, + "skipped_count": 10, + "error_count": 0, + "suite_error": null, + "test_cases": [ + { + "status": "failed", + "name": "Note associations is expected not to belong to project required: ", + "classname": "spec.models.note_spec", + "file": "./spec/models/note_spec.rb", + "execution_time": 0.209091, + "system_output": "Failure/Error: it { is_expected.not_to belong_to(:project) }\n Did not expect Note to have a belongs_to association called project\n./spec/models/note_spec.rb:9:in `block (3 levels) in '\n./spec/spec_helper.rb:392:in `block (3 levels) in '\n./spec/support/sidekiq_middleware.rb:9:in `with_sidekiq_server_middleware'\n./spec/spec_helper.rb:383:in `block (2 levels) in '\n./spec/spec_helper.rb:379:in `block (3 levels) in '\n./lib/gitlab/application_context.rb:31:in `with_raw_context'\n./spec/spec_helper.rb:379:in `block (2 levels) in '\n./spec/support/database/prevent_cross_joins.rb:95:in `block (3 levels) in '\n./spec/support/database/prevent_cross_joins.rb:62:in `with_cross_joins_prevented'\n./spec/support/database/prevent_cross_joins.rb:95:in `block (2 levels) in '", + "stack_trace": null, + "recent_failures": null + }, + { + "status": "success", + "name": "Gitlab::ImportExport yields the initial tree when importing and exporting it again", + "classname": "spec.lib.gitlab.import_export.import_export_equivalence_spec", + "file": "./spec/lib/gitlab/import_export/import_export_equivalence_spec.rb", + "execution_time": 17.084198, + "system_output": null, + "stack_trace": null, + "recent_failures": null + } + ] + } + ] +} diff --git a/spec/frontend/__helpers__/experimentation_helper.js b/spec/frontend/__helpers__/experimentation_helper.js index 7a2ef61216a..e0156226acc 100644 --- a/spec/frontend/__helpers__/experimentation_helper.js +++ b/spec/frontend/__helpers__/experimentation_helper.js @@ -1,5 +1,6 @@ import { merge } from 'lodash'; +// This helper is for specs that use `gitlab/experimentation` module export function withGonExperiment(experimentKey, value = true) { let origGon; @@ -12,16 +13,26 @@ export function withGonExperiment(experimentKey, value = true) { window.gon = origGon; }); } -// This helper is for specs that use `gitlab-experiment` utilities, which have a different schema that gets pushed via Gon compared to `Experimentation Module` -export function assignGitlabExperiment(experimentKey, variant) { - let origGon; - beforeEach(() => { - origGon = window.gon; - window.gon = { experiment: { [experimentKey]: { variant } } }; - }); +// The following helper is for specs that use `gitlab-experiment` utilities, +// which have a different schema that gets pushed to the frontend compared to +// the `Experimentation` Module. +// +// Usage: stubExperiments({ experiment_feature_flag_name: 'variant_name', ... }) +export function stubExperiments(experiments = {}) { + // Deprecated + window.gon = window.gon || {}; + window.gon.experiment = window.gon.experiment || {}; + // Preferred + window.gl = window.gl || {}; + window.gl.experiments = window.gl.experiemnts || {}; - afterEach(() => { - window.gon = origGon; + Object.entries(experiments).forEach(([name, variant]) => { + const experimentData = { experiment: name, variant }; + + // Deprecated + window.gon.experiment[name] = experimentData; + // Preferred + window.gl.experiments[name] = experimentData; }); } diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js index 4c491a87fcb..6b3f1f01e6a 100644 --- a/spec/frontend/__mocks__/@gitlab/ui.js +++ b/spec/frontend/__mocks__/@gitlab/ui.js @@ -14,7 +14,9 @@ export * from '@gitlab/ui'; */ jest.mock('@gitlab/ui/dist/directives/tooltip.js', () => ({ - bind() {}, + GlTooltipDirective: { + bind() {}, + }, })); jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () => ({ diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js index ee14e002f1b..c9a899ab78b 100644 --- a/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js +++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js @@ -1,7 +1,7 @@ import { GlBanner } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import DevopsScoreCallout from '~/analytics/devops_report/components/devops_score_callout.vue'; -import { INTRO_COOKIE_KEY } from '~/analytics/devops_report/constants'; +import DevopsScoreCallout from '~/analytics/devops_reports/components/devops_score_callout.vue'; +import { INTRO_COOKIE_KEY } from '~/analytics/devops_reports/constants'; import * as utils from '~/lib/utils/common_utils'; import { devopsReportDocsPath, devopsScoreIntroImagePath } from '../mock_data'; diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js index 8f8dac977de..824eb033671 100644 --- a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js +++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js @@ -2,8 +2,8 @@ import { GlTable, GlBadge, GlEmptyState } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import DevopsScore from '~/analytics/devops_report/components/devops_score.vue'; -import DevopsScoreCallout from '~/analytics/devops_report/components/devops_score_callout.vue'; +import DevopsScore from '~/analytics/devops_reports/components/devops_score.vue'; +import DevopsScoreCallout from '~/analytics/devops_reports/components/devops_score_callout.vue'; import { devopsScoreMetricsData, noDataImagePath, devopsScoreTableHeaders } from '../mock_data'; describe('DevopsScore', () => { diff --git a/spec/frontend/admin/deploy_keys/components/table_spec.js b/spec/frontend/admin/deploy_keys/components/table_spec.js new file mode 100644 index 00000000000..3b3be488043 --- /dev/null +++ b/spec/frontend/admin/deploy_keys/components/table_spec.js @@ -0,0 +1,47 @@ +import { merge } from 'lodash'; +import { GlTable, GlButton } from '@gitlab/ui'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import DeployKeysTable from '~/admin/deploy_keys/components/table.vue'; + +describe('DeployKeysTable', () => { + let wrapper; + + const defaultProvide = { + createPath: '/admin/deploy_keys/new', + deletePath: '/admin/deploy_keys/:id', + editPath: '/admin/deploy_keys/:id/edit', + emptyStateSvgPath: '/assets/illustrations/empty-state/empty-deploy-keys.svg', + }; + + const createComponent = (provide = {}) => { + wrapper = mountExtended(DeployKeysTable, { + provide: merge({}, defaultProvide, provide), + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders page title', () => { + createComponent(); + + expect(wrapper.findByText(DeployKeysTable.i18n.pageTitle).exists()).toBe(true); + }); + + it('renders table', () => { + createComponent(); + + expect(wrapper.findComponent(GlTable).exists()).toBe(true); + }); + + it('renders `New deploy key` button', () => { + createComponent(); + + const newDeployKeyButton = wrapper.findComponent(GlButton); + + expect(newDeployKeyButton.text()).toBe(DeployKeysTable.i18n.newDeployKeyButtonText); + expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath); + }); +}); diff --git a/spec/frontend/alert_handler_spec.js b/spec/frontend/alert_handler_spec.js index e4cd38a7799..228053b1b2b 100644 --- a/spec/frontend/alert_handler_spec.js +++ b/spec/frontend/alert_handler_spec.js @@ -26,12 +26,12 @@ describe('Alert Handler', () => { }); it('should render the alert', () => { - expect(findFirstAlert()).toExist(); + expect(findFirstAlert()).not.toBe(null); }); it('should dismiss the alert on click', () => { findFirstDismissButton().click(); - expect(findFirstAlert()).not.toExist(); + expect(findFirstAlert()).toBe(null); }); }); @@ -58,12 +58,12 @@ describe('Alert Handler', () => { }); it('should render the banner', () => { - expect(findFirstBanner()).toExist(); + expect(findFirstBanner()).not.toBe(null); }); it('should dismiss the banner on click', () => { findFirstDismissButton().click(); - expect(findFirstBanner()).not.toExist(); + expect(findFirstBanner()).toBe(null); }); }); @@ -79,12 +79,12 @@ describe('Alert Handler', () => { }); it('should render the banner', () => { - expect(findFirstAlert()).toExist(); + expect(findFirstAlert()).not.toBe(null); }); it('should dismiss the banner on click', () => { findFirstDismissButtonByClass().click(); - expect(findFirstAlert()).not.toExist(); + expect(findFirstAlert()).toBe(null); }); }); }); diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index 20e8bc059ec..39aab8dc1f8 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -40,7 +40,6 @@ describe('AlertManagementTable', () => { resolved: 11, all: 26, }; - const findDeprecationNotice = () => wrapper.findByTestId('alerts-deprecation-warning'); function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) { wrapper = extendedWrapper( @@ -49,7 +48,6 @@ describe('AlertManagementTable', () => { ...defaultProvideValues, alertManagementEnabled: true, userCanEnableAlertManagement: true, - hasManagedPrometheus: false, ...provide, }, data() { @@ -237,22 +235,6 @@ describe('AlertManagementTable', () => { expect(visitUrl).toHaveBeenCalledWith('/1527542/details', true); }); - it.each` - managedAlertsDeprecation | hasManagedPrometheus | isVisible - ${false} | ${false} | ${false} - ${false} | ${true} | ${true} - ${true} | ${false} | ${false} - ${true} | ${true} | ${false} - `( - 'when the deprecation feature flag is $managedAlertsDeprecation and has managed prometheus is $hasManagedPrometheus', - ({ hasManagedPrometheus, managedAlertsDeprecation, isVisible }) => { - mountComponent({ - provide: { hasManagedPrometheus, glFeatures: { managedAlertsDeprecation } }, - }); - expect(findDeprecationNotice().exists()).toBe(isVisible); - }, - ); - describe('alert issue links', () => { beforeEach(() => { mountComponent({ diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index 298596085ef..bdc1dde7d48 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -1,4 +1,12 @@ -import { GlForm, GlFormSelect, GlFormInput, GlToggle, GlFormTextarea, GlTab } from '@gitlab/ui'; +import { + GlForm, + GlFormSelect, + GlFormInput, + GlToggle, + GlFormTextarea, + GlTab, + GlLink, +} from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -58,7 +66,6 @@ describe('AlertsSettingsForm', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); @@ -69,7 +76,7 @@ describe('AlertsSettingsForm', () => { const enableIntegration = (index, value) => { findFormFields().at(index).setValue(value); - findFormToggle().trigger('click'); + findFormToggle().vm.$emit('change', true); }; describe('with default values', () => { @@ -102,6 +109,12 @@ describe('AlertsSettingsForm', () => { expect(findFormFields().at(0).attributes('id')).not.toBe('name-integration'); }); + it('verify pricing link url', () => { + createComponent({ props: { canAddIntegration: false } }); + const link = findMultiSupportText().findComponent(GlLink); + expect(link.attributes('href')).toMatch(/https:\/\/about.gitlab.(com|cn)\/pricing/); + }); + describe('form tabs', () => { it('renders 3 tabs', () => { expect(findTabs()).toHaveLength(3); diff --git a/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js b/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js deleted file mode 100644 index c5c40e9a360..00000000000 --- a/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { GlEmptyState, GlSprintf } from '@gitlab/ui'; -import { TEST_HOST } from 'helpers/test_constants'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import ServicePingDisabled from '~/analytics/devops_report/components/service_ping_disabled.vue'; - -describe('~/analytics/devops_report/components/service_ping_disabled.vue', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - }); - - const createWrapper = ({ isAdmin = false } = {}) => { - wrapper = mountExtended(ServicePingDisabled, { - provide: { - isAdmin, - svgPath: TEST_HOST, - primaryButtonPath: TEST_HOST, - }, - }); - }; - - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - const findMessageForRegularUsers = () => wrapper.findComponent(GlSprintf); - const findDocsLink = () => wrapper.findByRole('link', { name: 'service ping' }); - const findPowerOnButton = () => wrapper.findByRole('link', { name: 'Turn on service ping' }); - - it('renders empty state with provided SVG path', () => { - createWrapper(); - - expect(findEmptyState().props('svgPath')).toBe(TEST_HOST); - }); - - describe('for regular users', () => { - beforeEach(() => { - createWrapper({ isAdmin: false }); - }); - - it('renders message without power-on button', () => { - expect(findMessageForRegularUsers().exists()).toBe(true); - expect(findPowerOnButton().exists()).toBe(false); - }); - - it('renders docs link', () => { - expect(findDocsLink().exists()).toBe(true); - expect(findDocsLink().attributes('href')).toBe('/help/development/service_ping/index.md'); - }); - }); - - describe('for admins', () => { - beforeEach(() => { - createWrapper({ isAdmin: true }); - }); - - it('renders power-on button', () => { - expect(findPowerOnButton().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js b/spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js new file mode 100644 index 00000000000..c62bfb11f7b --- /dev/null +++ b/spec/frontend/analytics/devops_reports/components/service_ping_disabled_spec.js @@ -0,0 +1,59 @@ +import { GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ServicePingDisabled from '~/analytics/devops_reports/components/service_ping_disabled.vue'; + +describe('~/analytics/devops_reports/components/service_ping_disabled.vue', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + const createWrapper = ({ isAdmin = false } = {}) => { + wrapper = mountExtended(ServicePingDisabled, { + provide: { + isAdmin, + svgPath: TEST_HOST, + primaryButtonPath: TEST_HOST, + }, + }); + }; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findMessageForRegularUsers = () => wrapper.findComponent(GlSprintf); + const findDocsLink = () => wrapper.findByRole('link', { name: 'service ping' }); + const findPowerOnButton = () => wrapper.findByRole('link', { name: 'Turn on service ping' }); + + it('renders empty state with provided SVG path', () => { + createWrapper(); + + expect(findEmptyState().props('svgPath')).toBe(TEST_HOST); + }); + + describe('for regular users', () => { + beforeEach(() => { + createWrapper({ isAdmin: false }); + }); + + it('renders message without power-on button', () => { + expect(findMessageForRegularUsers().exists()).toBe(true); + expect(findPowerOnButton().exists()).toBe(false); + }); + + it('renders docs link', () => { + expect(findDocsLink().exists()).toBe(true); + expect(findDocsLink().attributes('href')).toBe('/help/development/service_ping/index.md'); + }); + }); + + describe('for admins', () => { + beforeEach(() => { + createWrapper({ isAdmin: true }); + }); + + it('renders power-on button', () => { + expect(findPowerOnButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js index 870375318e3..694c16a85c4 100644 --- a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js @@ -1,7 +1,6 @@ -import { within } from '@testing-library/dom'; -import { GlForm } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { GlForm, GlModal } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import ManageTwoFactorForm, { i18n, } from '~/authentication/two_factor_auth/components/manage_two_factor_form.vue'; @@ -17,100 +16,133 @@ describe('ManageTwoFactorForm', () => { let wrapper; const createComponent = (options = {}) => { - wrapper = extendedWrapper( - mount(ManageTwoFactorForm, { - provide: { - ...defaultProvide, - webauthnEnabled: options?.webauthnEnabled ?? false, - isCurrentPasswordRequired: options?.currentPasswordRequired ?? true, - }, - }), - ); + wrapper = mountExtended(ManageTwoFactorForm, { + provide: { + ...defaultProvide, + webauthnEnabled: options?.webauthnEnabled ?? false, + isCurrentPasswordRequired: options?.currentPasswordRequired ?? true, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: ` +
+ + + +
`, + }), + }, + }); }; - const queryByText = (text, options) => within(wrapper.element).queryByText(text, options); - const queryByLabelText = (text, options) => - within(wrapper.element).queryByLabelText(text, options); - const findForm = () => wrapper.findComponent(GlForm); const findMethodInput = () => wrapper.findByTestId('test-2fa-method-field'); const findDisableButton = () => wrapper.findByTestId('test-2fa-disable-button'); const findRegenerateCodesButton = () => wrapper.findByTestId('test-2fa-regenerate-codes-button'); + const findConfirmationModal = () => wrapper.findComponent(GlModal); + + const itShowsConfirmationModal = (confirmText) => { + it('shows confirmation modal', async () => { + await wrapper.findByLabelText('Current password').setValue('foo bar'); + await findDisableButton().trigger('click'); + + expect(findConfirmationModal().props('visible')).toBe(true); + expect(findConfirmationModal().html()).toContain(confirmText); + }); + }; + + const itShowsValidationMessageIfCurrentPasswordFieldIsEmpty = (findButtonFunction) => { + it('shows validation message if `Current password` is empty', async () => { + await findButtonFunction().trigger('click'); + + expect(wrapper.findByText(i18n.currentPasswordInvalidFeedback).exists()).toBe(true); + }); + }; beforeEach(() => { createComponent(); }); - describe('Current password field', () => { - it('renders the current password field', () => { - expect(queryByLabelText(i18n.currentPassword).tagName).toEqual('INPUT'); + describe('`Current password` field', () => { + describe('when required', () => { + it('renders the current password field', () => { + expect(wrapper.findByLabelText(i18n.currentPassword).exists()).toBe(true); + }); }); - }); - describe('when current password is not required', () => { - beforeEach(() => { - createComponent({ - currentPasswordRequired: false, + describe('when not required', () => { + beforeEach(() => { + createComponent({ + currentPasswordRequired: false, + }); }); - }); - it('does not render the current password field', () => { - expect(queryByLabelText(i18n.currentPassword)).toBe(null); + it('does not render the current password field', () => { + expect(wrapper.findByLabelText(i18n.currentPassword).exists()).toBe(false); + }); }); }); describe('Disable button', () => { it('renders the component with correct attributes', () => { expect(findDisableButton().exists()).toBe(true); - expect(findDisableButton().attributes()).toMatchObject({ - 'data-confirm': i18n.confirm, - 'data-form-action': defaultProvide.profileTwoFactorAuthPath, - 'data-form-method': defaultProvide.profileTwoFactorAuthMethod, - }); }); - it('has the right confirm text', () => { - expect(findDisableButton().attributes('data-confirm')).toBe(i18n.confirm); - }); + describe('when clicked', () => { + itShowsValidationMessageIfCurrentPasswordFieldIsEmpty(findDisableButton); - describe('when webauthnEnabled', () => { - beforeEach(() => { - createComponent({ - webauthnEnabled: true, + itShowsConfirmationModal(i18n.confirm); + + describe('when webauthnEnabled', () => { + beforeEach(() => { + createComponent({ + webauthnEnabled: true, + }); }); - }); - it('has the right confirm text', () => { - expect(findDisableButton().attributes('data-confirm')).toBe(i18n.confirmWebAuthn); + itShowsConfirmationModal(i18n.confirmWebAuthn); }); - }); - it('modifies the form action and method when submitted through the button', async () => { - const form = findForm(); - const disableButton = findDisableButton().element; - const methodInput = findMethodInput(); + it('modifies the form action and method when submitted through the button', async () => { + const form = findForm(); + const methodInput = findMethodInput(); + const submitSpy = jest.spyOn(form.element, 'submit'); + + await wrapper.findByLabelText('Current password').setValue('foo bar'); + await findDisableButton().trigger('click'); + + expect(form.attributes('action')).toBe(defaultProvide.profileTwoFactorAuthPath); + expect(methodInput.attributes('value')).toBe(defaultProvide.profileTwoFactorAuthMethod); - await form.vm.$emit('submit', { submitter: disableButton }); + findConfirmationModal().vm.$emit('primary'); - expect(form.attributes('action')).toBe(defaultProvide.profileTwoFactorAuthPath); - expect(methodInput.attributes('value')).toBe(defaultProvide.profileTwoFactorAuthMethod); + expect(submitSpy).toHaveBeenCalled(); + }); }); }); describe('Regenerate recovery codes button', () => { it('renders the button', () => { - expect(queryByText(i18n.regenerateRecoveryCodes)).toEqual(expect.any(HTMLElement)); + expect(findRegenerateCodesButton().exists()).toBe(true); }); - it('modifies the form action and method when submitted through the button', async () => { - const form = findForm(); - const regenerateCodesButton = findRegenerateCodesButton().element; - const methodInput = findMethodInput(); + describe('when clicked', () => { + itShowsValidationMessageIfCurrentPasswordFieldIsEmpty(findRegenerateCodesButton); + + it('modifies the form action and method when submitted through the button', async () => { + const form = findForm(); + const methodInput = findMethodInput(); + const submitSpy = jest.spyOn(form.element, 'submit'); - await form.vm.$emit('submit', { submitter: regenerateCodesButton }); + await wrapper.findByLabelText('Current password').setValue('foo bar'); + await findRegenerateCodesButton().trigger('click'); - expect(form.attributes('action')).toBe(defaultProvide.codesProfileTwoFactorAuthPath); - expect(methodInput.attributes('value')).toBe(defaultProvide.codesProfileTwoFactorAuthMethod); + expect(form.attributes('action')).toBe(defaultProvide.codesProfileTwoFactorAuthPath); + expect(methodInput.attributes('value')).toBe( + defaultProvide.codesProfileTwoFactorAuthMethod, + ); + expect(submitSpy).toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/batch_comments/components/preview_dropdown_spec.js b/spec/frontend/batch_comments/components/preview_dropdown_spec.js index 41be04d0b7e..5327879f003 100644 --- a/spec/frontend/batch_comments/components/preview_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/preview_dropdown_spec.js @@ -7,7 +7,7 @@ Vue.use(Vuex); let wrapper; -const toggleActiveFileByHash = jest.fn(); +const setCurrentFileHash = jest.fn(); const scrollToDraft = jest.fn(); function factory({ viewDiffsFileByFile = false, draftsCount = 1, sortedDrafts = [] } = {}) { @@ -16,7 +16,7 @@ function factory({ viewDiffsFileByFile = false, draftsCount = 1, sortedDrafts = diffs: { namespaced: true, actions: { - toggleActiveFileByHash, + setCurrentFileHash, }, state: { viewDiffsFileByFile, @@ -51,7 +51,7 @@ describe('Batch comments preview dropdown', () => { await Vue.nextTick(); - expect(toggleActiveFileByHash).toHaveBeenCalledWith(expect.anything(), 'hash'); + expect(setCurrentFileHash).toHaveBeenCalledWith(expect.anything(), 'hash'); expect(scrollToDraft).toHaveBeenCalledWith(expect.anything(), { id: 1, file_hash: 'hash' }); }); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index 286ed269421..d23a0a84997 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -56,13 +56,13 @@ describe('gl_emoji', () => { 'bomb emoji just with name attribute', '', '💣', - ':bomb:', + `:bomb:`, ], [ 'bomb emoji with name attribute and unicode version', '💣', '💣', - ':bomb:', + `:bomb:`, ], [ 'bomb emoji with sprite fallback', @@ -80,7 +80,7 @@ describe('gl_emoji', () => { 'invalid emoji', '', '', - ':grey_question:', + `:grey_question:`, ], ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { it(`renders correctly with emoji support`, async () => { diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap index 31fb6addcac..db9684239a1 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap @@ -4,9 +4,17 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = `
- +
+ + + +
{ it('renders all components', () => { createComponent(); + expect(wrapper.find(TableContents).exists()).toBe(true); expect(wrapper.find(ViewerSwitcher).exists()).toBe(true); expect(findDefaultActions().exists()).toBe(true); expect(wrapper.find(BlobFilepath).exists()).toBe(true); diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js index 09633dc5d5d..ade35d39b4f 100644 --- a/spec/frontend/blob/components/table_contents_spec.js +++ b/spec/frontend/blob/components/table_contents_spec.js @@ -32,10 +32,30 @@ describe('Markdown table of contents component', () => { }); describe('not loaded', () => { + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + it('does not populate dropdown', () => { createComponent(); - expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(false); + expect(findDropdownItem().exists()).toBe(false); + }); + + it('does not show dropdown when loading blob content', async () => { + createComponent(); + + await setLoaded(false); + + expect(findDropdownItem().exists()).toBe(false); + }); + + it('does not show dropdown when viewing non-rich content', async () => { + createComponent(); + + document.querySelector('.blob-viewer').setAttribute('data-type', 'simple'); + + await setLoaded(true); + + expect(findDropdownItem().exists()).toBe(false); }); }); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 25ec568e48d..5742dfdc5d2 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -64,12 +64,12 @@ describe('Board card', () => { }; const selectCard = async () => { - wrapper.trigger('mouseup'); + wrapper.trigger('click'); await wrapper.vm.$nextTick(); }; const multiSelectCard = async () => { - wrapper.trigger('mouseup', { ctrlKey: true }); + wrapper.trigger('click', { ctrlKey: true }); await wrapper.vm.$nextTick(); }; diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index dc93890f27a..b858d6e95a0 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -7,6 +7,7 @@ import { __ } from '~/locale'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; +import { createStore } from '~/boards/stores'; Vue.use(Vuex); @@ -42,17 +43,13 @@ describe('BoardFilteredSearch', () => { }, ]; - const createComponent = ({ initialFilterParams = {} } = {}) => { - store = new Vuex.Store({ - actions: { - performSearch: jest.fn(), - }, - }); - + const createComponent = ({ initialFilterParams = {}, props = {} } = {}) => { + store = createStore(); wrapper = shallowMount(BoardFilteredSearch, { provide: { initialFilterParams, fullPath: '' }, store, propsData: { + ...props, tokens, }, }); @@ -68,11 +65,7 @@ describe('BoardFilteredSearch', () => { beforeEach(() => { createComponent(); - jest.spyOn(store, 'dispatch'); - }); - - it('renders FilteredSearch', () => { - expect(findFilteredSearch().exists()).toBe(true); + jest.spyOn(store, 'dispatch').mockImplementation(); }); it('passes the correct tokens to FilteredSearch', () => { @@ -99,6 +92,22 @@ describe('BoardFilteredSearch', () => { }); }); + describe('when eeFilters is not empty', () => { + it('passes the correct initialFilterValue to FitleredSearchBarRoot', () => { + createComponent({ props: { eeFilters: { labelName: ['label'] } } }); + + expect(findFilteredSearch().props('initialFilterValue')).toEqual([ + { type: 'label_name', value: { data: 'label', operator: '=' } }, + ]); + }); + }); + + it('renders FilteredSearch', () => { + createComponent(); + + expect(findFilteredSearch().exists()).toBe(true); + }); + describe('when searching', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 52f1907654a..692fd3ec555 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,7 +1,6 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import setWindowLocation from 'helpers/set_window_location_helper'; -import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import BoardForm from '~/boards/components/board_form.vue'; @@ -18,21 +17,18 @@ jest.mock('~/lib/utils/url_utility', () => ({ })); const currentBoard = { - id: 1, + id: 'gid://gitlab/Board/1', name: 'test', labels: [], - milestone_id: undefined, + milestone: {}, assignee: {}, - assignee_id: undefined, weight: null, - hide_backlog_list: false, - hide_closed_list: false, + hideBacklogList: false, + hideClosedList: false, }; const defaultProps = { canAdminBoard: false, - labelsPath: `${TEST_HOST}/labels/path`, - labelsWebUrl: `${TEST_HOST}/-/labels`, currentBoard, currentPage: '', }; @@ -252,7 +248,7 @@ describe('BoardForm', () => { mutation: updateBoardMutation, variables: { input: expect.objectContaining({ - id: `gid://gitlab/Board/${currentBoard.id}`, + id: currentBoard.id, }), }, }); @@ -278,7 +274,7 @@ describe('BoardForm', () => { mutation: updateBoardMutation, variables: { input: expect.objectContaining({ - id: `gid://gitlab/Board/${currentBoard.id}`, + id: currentBoard.id, }), }, }); @@ -326,7 +322,7 @@ describe('BoardForm', () => { expect(mutate).toHaveBeenCalledWith({ mutation: destroyBoardMutation, variables: { - id: 'gid://gitlab/Board/1', + id: currentBoard.id, }, }); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index bf317b51e83..c841c17a029 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -1,13 +1,22 @@ import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import Vuex from 'vuex'; import { TEST_HOST } from 'spec/test_constants'; import BoardsSelector from '~/boards/components/boards_selector.vue'; +import groupBoardQuery from '~/boards/graphql/group_board.query.graphql'; +import projectBoardQuery from '~/boards/graphql/project_board.query.graphql'; +import defaultStore from '~/boards/stores'; import axios from '~/lib/utils/axios_utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockGroupBoardResponse, mockProjectBoardResponse } from '../mock_data'; const throttleDuration = 1; +Vue.use(VueApollo); + function boardGenerator(n) { return new Array(n).fill().map((board, index) => { const id = `${index}`; @@ -25,9 +34,27 @@ describe('BoardsSelector', () => { let allBoardsResponse; let recentBoardsResponse; let mock; + let fakeApollo; + let store; const boards = boardGenerator(20); const recentBoards = boardGenerator(5); + const createStore = ({ isGroupBoard = false, isProjectBoard = false } = {}) => { + store = new Vuex.Store({ + ...defaultStore, + actions: { + setError: jest.fn(), + }, + getters: { + isGroupBoard: () => isGroupBoard, + isProjectBoard: () => isProjectBoard, + }, + state: { + boardType: isGroupBoard ? 'group' : 'project', + }, + }); + }; + const fillSearchBox = (filterTerm) => { const searchBox = wrapper.find({ ref: 'searchBox' }); const searchBoxInput = searchBox.find('input'); @@ -40,52 +67,27 @@ describe('BoardsSelector', () => { const getLoadingIcon = () => wrapper.find(GlLoadingIcon); const findDropdown = () => wrapper.find(GlDropdown); - beforeEach(() => { - mock = new MockAdapter(axios); - const $apollo = { - queries: { - boards: { - loading: false, - }, - }, - }; + const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse); + const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse); - allBoardsResponse = Promise.resolve({ - data: { - group: { - boards: { - edges: boards.map((board) => ({ node: board })), - }, - }, - }, - }); - recentBoardsResponse = Promise.resolve({ - data: recentBoards, - }); + const createComponent = () => { + fakeApollo = createMockApollo([ + [projectBoardQuery, projectBoardQueryHandlerSuccess], + [groupBoardQuery, groupBoardQueryHandlerSuccess], + ]); wrapper = mount(BoardsSelector, { + store, + apolloProvider: fakeApollo, propsData: { throttleDuration, - currentBoard: { - id: 1, - name: 'Development', - milestone_id: null, - weight: null, - assignee_id: null, - labels: [], - }, boardBaseUrl: `${TEST_HOST}/board/base/url`, hasMissingBoards: false, canAdminBoard: true, multipleIssueBoardsAvailable: true, - labelsPath: `${TEST_HOST}/labels/path`, - labelsWebUrl: `${TEST_HOST}/labels`, - projectId: 42, - groupId: 19, scopedIssueBoardFeatureEnabled: true, weights: [], }, - mocks: { $apollo }, attachTo: document.body, provide: { fullPath: '', @@ -98,12 +100,7 @@ describe('BoardsSelector', () => { [options.loadingKey]: true, }); }); - - mock.onGet(`${TEST_HOST}/recent`).replyOnce(200, recentBoards); - - // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - findDropdown().vm.$emit('show'); - }); + }; afterEach(() => { wrapper.destroy(); @@ -111,104 +108,158 @@ describe('BoardsSelector', () => { mock.restore(); }); - describe('loading', () => { - // we are testing loading state, so don't resolve responses until after the tests - afterEach(() => { - return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); - }); + describe('fetching all boards', () => { + beforeEach(() => { + mock = new MockAdapter(axios); - it('shows loading spinner', () => { - expect(getDropdownHeaders()).toHaveLength(0); - expect(getDropdownItems()).toHaveLength(0); - expect(getLoadingIcon().exists()).toBe(true); + allBoardsResponse = Promise.resolve({ + data: { + group: { + boards: { + edges: boards.map((board) => ({ node: board })), + }, + }, + }, + }); + recentBoardsResponse = Promise.resolve({ + data: recentBoards, + }); + + createStore(); + createComponent(); + + mock.onGet(`${TEST_HOST}/recent`).replyOnce(200, recentBoards); }); - }); - describe('loaded', () => { - beforeEach(async () => { - await wrapper.setData({ - loadingBoards: false, + describe('loading', () => { + beforeEach(async () => { + // Wait for current board to be loaded + await nextTick(); + + // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time + findDropdown().vm.$emit('show'); + }); + + // we are testing loading state, so don't resolve responses until after the tests + afterEach(async () => { + await Promise.all([allBoardsResponse, recentBoardsResponse]); + await nextTick(); }); - return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); - }); - it('hides loading spinner', async () => { - await wrapper.vm.$nextTick(); - expect(getLoadingIcon().exists()).toBe(false); + it('shows loading spinner', () => { + expect(getDropdownHeaders()).toHaveLength(0); + expect(getDropdownItems()).toHaveLength(0); + expect(getLoadingIcon().exists()).toBe(true); + }); }); - describe('filtering', () => { - beforeEach(() => { - wrapper.setData({ - boards, - }); + describe('loaded', () => { + beforeEach(async () => { + // Wait for current board to be loaded + await nextTick(); + + // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time + findDropdown().vm.$emit('show'); - return nextTick(); + await wrapper.setData({ + loadingBoards: false, + loadingRecentBoards: false, + }); + await Promise.all([allBoardsResponse, recentBoardsResponse]); + await nextTick(); }); - it('shows all boards without filtering', () => { - expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length); + it('hides loading spinner', async () => { + await nextTick(); + expect(getLoadingIcon().exists()).toBe(false); }); - it('shows only matching boards when filtering', () => { - const filterTerm = 'board1'; - const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length; + describe('filtering', () => { + beforeEach(async () => { + wrapper.setData({ + boards, + }); + + await nextTick(); + }); - fillSearchBox(filterTerm); + it('shows all boards without filtering', () => { + expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length); + }); - return nextTick().then(() => { + it('shows only matching boards when filtering', async () => { + const filterTerm = 'board1'; + const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length; + + fillSearchBox(filterTerm); + + await nextTick(); expect(getDropdownItems()).toHaveLength(expectedCount); }); - }); - it('shows message if there are no matching boards', () => { - fillSearchBox('does not exist'); + it('shows message if there are no matching boards', async () => { + fillSearchBox('does not exist'); - return nextTick().then(() => { + await nextTick(); expect(getDropdownItems()).toHaveLength(0); expect(wrapper.text().includes('No matching boards found')).toBe(true); }); }); - }); - describe('recent boards section', () => { - it('shows only when boards are greater than 10', () => { - wrapper.setData({ - boards, - }); + describe('recent boards section', () => { + it('shows only when boards are greater than 10', async () => { + wrapper.setData({ + boards, + }); - return nextTick().then(() => { + await nextTick(); expect(getDropdownHeaders()).toHaveLength(2); }); - }); - it('does not show when boards are less than 10', () => { - wrapper.setData({ - boards: boards.slice(0, 5), - }); + it('does not show when boards are less than 10', async () => { + wrapper.setData({ + boards: boards.slice(0, 5), + }); - return nextTick().then(() => { + await nextTick(); expect(getDropdownHeaders()).toHaveLength(0); }); - }); - it('does not show when recentBoards api returns empty array', () => { - wrapper.setData({ - recentBoards: [], - }); + it('does not show when recentBoards api returns empty array', async () => { + wrapper.setData({ + recentBoards: [], + }); - return nextTick().then(() => { + await nextTick(); expect(getDropdownHeaders()).toHaveLength(0); }); - }); - it('does not show when search is active', () => { - fillSearchBox('Random string'); + it('does not show when search is active', async () => { + fillSearchBox('Random string'); - return nextTick().then(() => { + await nextTick(); expect(getDropdownHeaders()).toHaveLength(0); }); }); }); }); + + describe('fetching current board', () => { + it.each` + boardType | queryHandler | notCalledHandler + ${'group'} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess} + ${'project'} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess} + `('fetches $boardType board', async ({ boardType, queryHandler, notCalledHandler }) => { + createStore({ + isProjectBoard: boardType === 'project', + isGroupBoard: boardType === 'group', + }); + createComponent(); + + await nextTick(); + + expect(queryHandler).toHaveBeenCalled(); + expect(notCalledHandler).not.toHaveBeenCalled(); + }); + }); }); 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 b6de46f8db8..45c5c87d800 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; +import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue'; import IssueBoardFilteredSpec from '~/boards/components/issue_board_filtered_search.vue'; import issueBoardFilters from '~/boards/issue_board_filters'; import { mockTokens } from '../mock_data'; @@ -9,39 +9,60 @@ jest.mock('~/boards/issue_board_filters'); describe('IssueBoardFilter', () => { let wrapper; - const createComponent = () => { + const findBoardsFilteredSearch = () => wrapper.findComponent(BoardFilteredSearch); + + const createComponent = ({ isSignedIn = false } = {}) => { wrapper = shallowMount(IssueBoardFilteredSpec, { - props: { fullPath: '', boardType: '' }, + propsData: { fullPath: 'gitlab-org', boardType: 'group' }, + provide: { + isSignedIn, + }, }); }; + let fetchAuthorsSpy; + let fetchLabelsSpy; + beforeEach(() => { + fetchAuthorsSpy = jest.fn(); + fetchLabelsSpy = jest.fn(); + + issueBoardFilters.mockReturnValue({ + fetchAuthors: fetchAuthorsSpy, + fetchLabels: fetchLabelsSpy, + }); + }); + afterEach(() => { wrapper.destroy(); }); describe('default', () => { - let fetchAuthorsSpy; - let fetchLabelsSpy; beforeEach(() => { - fetchAuthorsSpy = jest.fn(); - fetchLabelsSpy = jest.fn(); - - issueBoardFilters.mockReturnValue({ - fetchAuthors: fetchAuthorsSpy, - fetchLabels: fetchLabelsSpy, - }); - createComponent(); }); it('finds BoardFilteredSearch', () => { - expect(wrapper.find(BoardFilteredSearch).exists()).toBe(true); + expect(findBoardsFilteredSearch().exists()).toBe(true); }); - it('passes the correct tokens to BoardFilteredSearch', () => { - const tokens = mockTokens(fetchLabelsSpy, fetchAuthorsSpy, wrapper.vm.fetchMilestones); + it.each` + isSignedIn + ${true} + ${false} + `( + 'passes the correct tokens to BoardFilteredSearch when user sign in is $isSignedIn', + ({ isSignedIn }) => { + createComponent({ isSignedIn }); - expect(wrapper.find(BoardFilteredSearch).props('tokens')).toEqual(tokens); - }); + const tokens = mockTokens( + fetchLabelsSpy, + fetchAuthorsSpy, + wrapper.vm.fetchMilestones, + isSignedIn, + ); + + expect(findBoardsFilteredSearch().props('tokens')).toEqual(tokens); + }, + ); }); }); diff --git a/spec/frontend/boards/components/new_board_button_spec.js b/spec/frontend/boards/components/new_board_button_spec.js new file mode 100644 index 00000000000..075fe225ec2 --- /dev/null +++ b/spec/frontend/boards/components/new_board_button_spec.js @@ -0,0 +1,75 @@ +import { mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import NewBoardButton from '~/boards/components/new_board_button.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { stubExperiments } from 'helpers/experimentation_helper'; +import eventHub from '~/boards/eventhub'; + +const FEATURE = 'prominent_create_board_btn'; + +describe('NewBoardButton', () => { + let wrapper; + + const createComponent = (args = {}) => + extendedWrapper( + mount(NewBoardButton, { + provide: { + canAdminBoard: true, + multipleIssueBoardsAvailable: true, + ...args, + }, + }), + ); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('control variant', () => { + beforeAll(() => { + stubExperiments({ [FEATURE]: 'control' }); + }); + + it('renders nothing', () => { + wrapper = createComponent(); + + expect(wrapper.text()).toBe(''); + }); + }); + + describe('candidate variant', () => { + beforeAll(() => { + stubExperiments({ [FEATURE]: 'candidate' }); + }); + + it('renders New board button when `candidate` variant', () => { + wrapper = createComponent(); + + expect(wrapper.text()).toBe('New board'); + }); + + it('renders nothing when `canAdminBoard` is `false`', () => { + wrapper = createComponent({ canAdminBoard: false }); + + expect(wrapper.find(GlButton).exists()).toBe(false); + }); + + it('renders nothing when `multipleIssueBoardsAvailable` is `false`', () => { + wrapper = createComponent({ multipleIssueBoardsAvailable: false }); + + expect(wrapper.find(GlButton).exists()).toBe(false); + }); + + it('emits `showBoardModal` when button is clicked', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + + wrapper = createComponent(); + + wrapper.find(GlButton).vm.$emit('click', { preventDefault: () => {} }); + + expect(eventHub.$emit).toHaveBeenCalledWith('showBoardModal', 'new'); + }); + }); +}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index 60474767f2d..fb9d823107e 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -105,6 +105,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { describe('when labels are updated over existing labels', () => { const testLabelsPayload = [ { id: 5, set: true }, + { id: 6, set: false }, { id: 7, set: true }, ]; const expectedLabels = [{ id: 5 }, { id: 7 }]; diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js index 8847f626c1f..6e1b528babc 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js @@ -14,8 +14,8 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = let store; const findNotificationHeader = () => wrapper.find("[data-testid='notification-header-text']"); - const findToggle = () => wrapper.find(GlToggle); - const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findToggle = () => wrapper.findComponent(GlToggle); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const createComponent = (activeBoardItem = { ...mockActiveIssue }) => { store = createStore(); @@ -32,7 +32,6 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = afterEach(() => { wrapper.destroy(); - wrapper = null; store = null; jest.clearAllMocks(); }); @@ -104,7 +103,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = expect(findGlLoadingIcon().exists()).toBe(false); - findToggle().trigger('click'); + findToggle().vm.$emit('change'); await wrapper.vm.$nextTick(); @@ -129,7 +128,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = expect(findGlLoadingIcon().exists()).toBe(false); - findToggle().trigger('click'); + findToggle().vm.$emit('change'); await wrapper.vm.$nextTick(); @@ -152,7 +151,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = }); jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); - findToggle().trigger('click'); + findToggle().vm.$emit('change'); await wrapper.vm.$nextTick(); expect(wrapper.vm.setError).toHaveBeenCalled(); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 6a4f344bbfb..8fcad99f8a7 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -4,6 +4,7 @@ import { ListType } from '~/boards/constants'; import { __ } from '~/locale'; import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; @@ -12,6 +13,7 @@ export const boardObj = { id: 1, name: 'test', milestone_id: null, + labels: [], }; export const listObj = { @@ -29,17 +31,27 @@ export const listObj = { }, }; -export const listObjDuplicate = { - id: listObj.id, - position: 1, - title: 'Test', - list_type: 'label', - weight: 3, - label: { - id: listObj.label.id, - title: 'Test', - color: '#ff0000', - description: 'testing;', +export const mockGroupBoardResponse = { + data: { + workspace: { + board: { + id: 'gid://gitlab/Board/1', + name: 'Development', + }, + __typename: 'Group', + }, + }, +}; + +export const mockProjectBoardResponse = { + data: { + workspace: { + board: { + id: 'gid://gitlab/Board/2', + name: 'Development', + }, + __typename: 'Project', + }, }, }; @@ -538,7 +550,16 @@ export const mockMoveData = { ...mockMoveIssueParams, }; -export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ +export const mockEmojiToken = { + type: 'my_reaction_emoji', + icon: 'thumb-up', + title: 'My-Reaction', + unique: true, + token: EmojiToken, + fetchEmojis: expect.any(Function), +}; + +export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji) => [ { icon: 'user', title: __('Assignee'), @@ -579,6 +600,7 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ symbol: '~', fetchLabels, }, + ...(hasEmoji ? [mockEmojiToken] : []), { icon: 'clock', title: __('Milestone'), @@ -593,7 +615,6 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ icon: 'issues', title: __('Type'), type: 'types', - operators: [{ value: '=', description: 'is' }], token: GlFilteredSearchToken, unique: true, options: [ @@ -609,3 +630,43 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ unique: true, }, ]; + +export const mockLabel1 = { + id: 'gid://gitlab/GroupLabel/121', + title: 'To Do', + color: '#F0AD4E', + textColor: '#FFFFFF', + description: null, +}; + +export const mockLabel2 = { + id: 'gid://gitlab/GroupLabel/122', + title: 'Doing', + color: '#F0AD4E', + textColor: '#FFFFFF', + description: null, +}; + +export const mockProjectLabelsResponse = { + data: { + workspace: { + id: 'gid://gitlab/Project/1', + labels: { + nodes: [mockLabel1, mockLabel2], + }, + __typename: 'Project', + }, + }, +}; + +export const mockGroupLabelsResponse = { + data: { + workspace: { + id: 'gid://gitlab/Group/1', + labels: { + nodes: [mockLabel1, mockLabel2], + }, + __typename: 'Group', + }, + }, +}; diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 0b90912a584..e245325b956 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -27,6 +27,7 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql' import actions from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { mockLists, @@ -1572,12 +1573,13 @@ describe('setActiveIssueLabels', () => { const getters = { activeBoardItem: mockIssue }; const testLabelIds = labels.map((label) => label.id); const input = { - addLabelIds: testLabelIds, + labelIds: testLabelIds, removeLabelIds: [], projectPath: 'h/b', + labels, }; - it('should assign labels on success, and sets loading state for labels', (done) => { + it('should assign labels on success', (done) => { jest .spyOn(gqlClient, 'mutate') .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); @@ -1593,14 +1595,6 @@ describe('setActiveIssueLabels', () => { input, { ...state, ...getters }, [ - { - type: types.SET_LABELS_LOADING, - payload: true, - }, - { - type: types.SET_LABELS_LOADING, - payload: false, - }, { type: types.UPDATE_BOARD_ITEM_BY_ID, payload, @@ -1618,6 +1612,64 @@ describe('setActiveIssueLabels', () => { await expect(actions.setActiveIssueLabels({ getters }, input)).rejects.toThrow(Error); }); + + describe('labels_widget FF on', () => { + beforeEach(() => { + window.gon = { + features: { labelsWidget: true }, + }; + + getters.activeBoardItem = { ...mockIssue, labels }; + }); + + afterEach(() => { + window.gon = { + features: {}, + }; + }); + + it('should assign labels', () => { + const payload = { + itemId: getters.activeBoardItem.id, + prop: 'labels', + value: labels, + }; + + testAction( + actions.setActiveIssueLabels, + input, + { ...state, ...getters }, + [ + { + type: types.UPDATE_BOARD_ITEM_BY_ID, + payload, + }, + ], + [], + ); + }); + + it('should remove label', () => { + const payload = { + itemId: getters.activeBoardItem.id, + prop: 'labels', + value: [labels[1]], + }; + + testAction( + actions.setActiveIssueLabels, + { ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] }, + { ...state, ...getters }, + [ + { + type: types.UPDATE_BOARD_ITEM_BY_ID, + payload, + }, + ], + [], + ); + }); + }); }); describe('setActiveItemSubscribed', () => { diff --git a/spec/frontend/chronic_duration_spec.js b/spec/frontend/chronic_duration_spec.js new file mode 100644 index 00000000000..32652e13dfc --- /dev/null +++ b/spec/frontend/chronic_duration_spec.js @@ -0,0 +1,354 @@ +/* + * NOTE: + * Changes to this file should be kept in sync with + * https://gitlab.com/gitlab-org/gitlab-chronic-duration/-/blob/master/spec/lib/chronic_duration_spec.rb. + */ + +/* + * This code is based on code from + * https://gitlab.com/gitlab-org/gitlab-chronic-duration and is + * distributed under the following license: + * + * MIT License + * + * Copyright (c) Henry Poydar + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +import { + parseChronicDuration, + outputChronicDuration, + DurationParseError, +} from '~/chronic_duration'; + +describe('parseChronicDuration', () => { + /* + * TODO The Ruby implementation of this algorithm uses the Numerizer module, + * which converts strings like "forty two" to "42", but there is no + * JavaScript equivalent of Numerizer. Skip it for now until Numerizer is + * ported to JavaScript. + */ + const EXEMPLARS = { + '1:20': 60 + 20, + '1:20.51': 60 + 20.51, + '4:01:01': 4 * 3600 + 60 + 1, + '3 mins 4 sec': 3 * 60 + 4, + '3 Mins 4 Sec': 3 * 60 + 4, + // 'three mins four sec': 3 * 60 + 4, + '2 hrs 20 min': 2 * 3600 + 20 * 60, + '2h20min': 2 * 3600 + 20 * 60, + '6 mos 1 day': 6 * 30 * 24 * 3600 + 24 * 3600, + '1 year 6 mos 1 day': 1 * 31557600 + 6 * 30 * 24 * 3600 + 24 * 3600, + '2.5 hrs': 2.5 * 3600, + '47 yrs 6 mos and 4.5d': 47 * 31557600 + 6 * 30 * 24 * 3600 + 4.5 * 24 * 3600, + // 'two hours and twenty minutes': 2 * 3600 + 20 * 60, + // 'four hours and forty minutes': 4 * 3600 + 40 * 60, + // 'four hours, and fourty minutes': 4 * 3600 + 40 * 60, + '3 weeks and, 2 days': 3600 * 24 * 7 * 3 + 3600 * 24 * 2, + '3 weeks, plus 2 days': 3600 * 24 * 7 * 3 + 3600 * 24 * 2, + '3 weeks with 2 days': 3600 * 24 * 7 * 3 + 3600 * 24 * 2, + '1 month': 3600 * 24 * 30, + '2 months': 3600 * 24 * 30 * 2, + '18 months': 3600 * 24 * 30 * 18, + '1 year 6 months': 3600 * 24 * (365.25 + 6 * 30), + day: 3600 * 24, + 'minute 30s': 90, + }; + + describe("when string can't be parsed", () => { + it('returns null', () => { + expect(parseChronicDuration('gobblygoo')).toBeNull(); + }); + + it('cannot parse zero', () => { + expect(parseChronicDuration('0')).toBeNull(); + }); + + describe('when .raiseExceptions set to true', () => { + it('raises with DurationParseError', () => { + expect(() => parseChronicDuration('23 gobblygoos', { raiseExceptions: true })).toThrowError( + DurationParseError, + ); + }); + + it('does not raise when string is empty', () => { + expect(parseChronicDuration('', { raiseExceptions: true })).toBeNull(); + }); + }); + }); + + it('should return zero if the string parses as zero and the .keepZero option is true', () => { + expect(parseChronicDuration('0', { keepZero: true })).toBe(0); + }); + + it('should return a float if seconds are in decimals', () => { + expect(parseChronicDuration('12 mins 3.141 seconds')).toBeCloseTo(723.141, 4); + }); + + it('should return an integer unless the seconds are in decimals', () => { + expect(parseChronicDuration('12 mins 3 seconds')).toBe(723); + }); + + it('should be able to parse minutes by default', () => { + expect(parseChronicDuration('5', { defaultUnit: 'minutes' })).toBe(300); + }); + + Object.entries(EXEMPLARS).forEach(([k, v]) => { + it(`parses a duration like ${k}`, () => { + expect(parseChronicDuration(k)).toBe(v); + }); + }); + + describe('with .hoursPerDay and .daysPerMonth params', () => { + it('uses provided .hoursPerDay', () => { + expect(parseChronicDuration('1d', { hoursPerDay: 24 })).toBe(24 * 60 * 60); + expect(parseChronicDuration('1d', { hoursPerDay: 8 })).toBe(8 * 60 * 60); + }); + + it('uses provided .daysPerMonth', () => { + expect(parseChronicDuration('1mo', { daysPerMonth: 30 })).toBe(30 * 24 * 60 * 60); + expect(parseChronicDuration('1mo', { daysPerMonth: 20 })).toBe(20 * 24 * 60 * 60); + + expect(parseChronicDuration('1w', { daysPerMonth: 30 })).toBe(7 * 24 * 60 * 60); + expect(parseChronicDuration('1w', { daysPerMonth: 20 })).toBe(5 * 24 * 60 * 60); + }); + + it('uses provided both .hoursPerDay and .daysPerMonth', () => { + expect(parseChronicDuration('1mo', { daysPerMonth: 30, hoursPerDay: 24 })).toBe( + 30 * 24 * 60 * 60, + ); + expect(parseChronicDuration('1mo', { daysPerMonth: 20, hoursPerDay: 8 })).toBe( + 20 * 8 * 60 * 60, + ); + + expect(parseChronicDuration('1w', { daysPerMonth: 30, hoursPerDay: 24 })).toBe( + 7 * 24 * 60 * 60, + ); + expect(parseChronicDuration('1w', { daysPerMonth: 20, hoursPerDay: 8 })).toBe( + 5 * 8 * 60 * 60, + ); + }); + }); +}); + +describe('outputChronicDuration', () => { + const EXEMPLARS = { + [60 + 20]: { + micro: '1m20s', + short: '1m 20s', + default: '1 min 20 secs', + long: '1 minute 20 seconds', + chrono: '1:20', + }, + [60 + 20.51]: { + micro: '1m20.51s', + short: '1m 20.51s', + default: '1 min 20.51 secs', + long: '1 minute 20.51 seconds', + chrono: '1:20.51', + }, + [60 + 20.51928]: { + micro: '1m20.51928s', + short: '1m 20.51928s', + default: '1 min 20.51928 secs', + long: '1 minute 20.51928 seconds', + chrono: '1:20.51928', + }, + [4 * 3600 + 60 + 1]: { + micro: '4h1m1s', + short: '4h 1m 1s', + default: '4 hrs 1 min 1 sec', + long: '4 hours 1 minute 1 second', + chrono: '4:01:01', + }, + [2 * 3600 + 20 * 60]: { + micro: '2h20m', + short: '2h 20m', + default: '2 hrs 20 mins', + long: '2 hours 20 minutes', + chrono: '2:20', + }, + [2 * 3600 + 20 * 60]: { + micro: '2h20m', + short: '2h 20m', + default: '2 hrs 20 mins', + long: '2 hours 20 minutes', + chrono: '2:20:00', + }, + [6 * 30 * 24 * 3600 + 24 * 3600]: { + micro: '6mo1d', + short: '6mo 1d', + default: '6 mos 1 day', + long: '6 months 1 day', + chrono: '6:01:00:00:00', // Yuck. FIXME + }, + [365.25 * 24 * 3600 + 24 * 3600]: { + micro: '1y1d', + short: '1y 1d', + default: '1 yr 1 day', + long: '1 year 1 day', + chrono: '1:00:01:00:00:00', + }, + [3 * 365.25 * 24 * 3600 + 24 * 3600]: { + micro: '3y1d', + short: '3y 1d', + default: '3 yrs 1 day', + long: '3 years 1 day', + chrono: '3:00:01:00:00:00', + }, + [3600 * 24 * 30 * 18]: { + micro: '18mo', + short: '18mo', + default: '18 mos', + long: '18 months', + chrono: '18:00:00:00:00', + }, + }; + + Object.entries(EXEMPLARS).forEach(([k, v]) => { + const kf = parseFloat(k); + Object.entries(v).forEach(([key, val]) => { + it(`properly outputs a duration of ${kf} seconds as ${val} using the ${key} format option`, () => { + expect(outputChronicDuration(kf, { format: key })).toBe(val); + }); + }); + }); + + const KEEP_ZERO_EXEMPLARS = { + true: { + micro: '0s', + short: '0s', + default: '0 secs', + long: '0 seconds', + chrono: '0', + }, + '': { + micro: null, + short: null, + default: null, + long: null, + chrono: '0', + }, + }; + + Object.entries(KEEP_ZERO_EXEMPLARS).forEach(([k, v]) => { + const kb = Boolean(k); + Object.entries(v).forEach(([key, val]) => { + it(`should properly output a duration of 0 seconds as ${val} using the ${key} format option, if the .keepZero option is ${kb}`, () => { + expect(outputChronicDuration(0, { format: key, keepZero: kb })).toBe(val); + }); + }); + }); + + it('returns weeks when needed', () => { + expect(outputChronicDuration(45 * 24 * 60 * 60, { weeks: true })).toMatch(/.*wk.*/); + }); + + it('returns hours and minutes only when .limitToHours option specified', () => { + expect(outputChronicDuration(395 * 24 * 60 * 60 + 15 * 60, { limitToHours: true })).toBe( + '9480 hrs 15 mins', + ); + }); + + describe('with .hoursPerDay and .daysPerMonth params', () => { + it('uses provided .hoursPerDay', () => { + expect(outputChronicDuration(24 * 60 * 60, { hoursPerDay: 24 })).toBe('1 day'); + expect(outputChronicDuration(24 * 60 * 60, { hoursPerDay: 8 })).toBe('3 days'); + }); + + it('uses provided .daysPerMonth', () => { + expect(outputChronicDuration(7 * 24 * 60 * 60, { weeks: true, daysPerMonth: 30 })).toBe( + '1 wk', + ); + expect(outputChronicDuration(7 * 24 * 60 * 60, { weeks: true, daysPerMonth: 20 })).toBe( + '1 wk 2 days', + ); + }); + + it('uses provided both .hoursPerDay and .daysPerMonth', () => { + expect( + outputChronicDuration(7 * 24 * 60 * 60, { weeks: true, daysPerMonth: 30, hoursPerDay: 24 }), + ).toBe('1 wk'); + expect( + outputChronicDuration(5 * 8 * 60 * 60, { weeks: true, daysPerMonth: 20, hoursPerDay: 8 }), + ).toBe('1 wk'); + }); + + it('uses provided params alongside with .weeks when converting to months', () => { + expect(outputChronicDuration(30 * 24 * 60 * 60, { daysPerMonth: 30, hoursPerDay: 24 })).toBe( + '1 mo', + ); + expect( + outputChronicDuration(30 * 24 * 60 * 60, { + daysPerMonth: 30, + hoursPerDay: 24, + weeks: true, + }), + ).toBe('1 mo 2 days'); + + expect(outputChronicDuration(20 * 8 * 60 * 60, { daysPerMonth: 20, hoursPerDay: 8 })).toBe( + '1 mo', + ); + expect( + outputChronicDuration(20 * 8 * 60 * 60, { daysPerMonth: 20, hoursPerDay: 8, weeks: true }), + ).toBe('1 mo'); + }); + }); + + it('returns the specified number of units if provided', () => { + expect(outputChronicDuration(4 * 3600 + 60 + 1, { units: 2 })).toBe('4 hrs 1 min'); + expect( + outputChronicDuration(6 * 30 * 24 * 3600 + 24 * 3600 + 3600 + 60 + 1, { + units: 3, + format: 'long', + }), + ).toBe('6 months 1 day 1 hour'); + }); + + describe('when the format is not specified', () => { + it('uses the default format', () => { + expect(outputChronicDuration(2 * 3600 + 20 * 60)).toBe('2 hrs 20 mins'); + }); + }); + + Object.entries(EXEMPLARS).forEach(([seconds, formatSpec]) => { + const secondsF = parseFloat(seconds); + Object.keys(formatSpec).forEach((format) => { + it(`outputs a duration for ${seconds} that parses back to the same thing when using the ${format} format`, () => { + expect(parseChronicDuration(outputChronicDuration(secondsF, { format }))).toBe(secondsF); + }); + }); + }); + + it('uses user-specified joiner if provided', () => { + expect(outputChronicDuration(2 * 3600 + 20 * 60, { joiner: ', ' })).toBe('2 hrs, 20 mins'); + }); +}); + +describe('work week', () => { + it('should parse knowing the work week', () => { + const week = parseChronicDuration('5d', { hoursPerDay: 8, daysPerMonth: 20 }); + expect(parseChronicDuration('40h', { hoursPerDay: 8, daysPerMonth: 20 })).toBe(week); + expect(parseChronicDuration('1w', { hoursPerDay: 8, daysPerMonth: 20 })).toBe(week); + }); +}); diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js index fd04ff8b3e7..c502e7d813e 100644 --- a/spec/frontend/clusters/agents/components/show_spec.js +++ b/spec/frontend/clusters/agents/components/show_spec.js @@ -1,6 +1,8 @@ import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlSprintf, GlTab } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ClusterAgentShow from '~/clusters/agents/components/show.vue'; import TokenTable from '~/clusters/agents/components/token_table.vue'; import getAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql'; @@ -40,28 +42,34 @@ describe('ClusterAgentShow', () => { queryResponse || jest.fn().mockResolvedValue({ data: { project: { clusterAgent } } }); const apolloProvider = createMockApollo([[getAgentQuery, agentQueryResponse]]); - wrapper = shallowMount(ClusterAgentShow, { - localVue, - apolloProvider, - propsData, - stubs: { GlSprintf, TimeAgoTooltip, GlTab }, - }); + wrapper = extendedWrapper( + shallowMount(ClusterAgentShow, { + localVue, + apolloProvider, + propsData, + stubs: { GlSprintf, TimeAgoTooltip, GlTab }, + }), + ); }; - const createWrapperWithoutApollo = ({ clusterAgent, loading = false }) => { + const createWrapperWithoutApollo = ({ clusterAgent, loading = false, slots = {} }) => { const $apollo = { queries: { clusterAgent: { loading } } }; - wrapper = shallowMount(ClusterAgentShow, { - propsData, - mocks: { $apollo, clusterAgent }, - stubs: { GlTab }, - }); + wrapper = extendedWrapper( + shallowMount(ClusterAgentShow, { + propsData, + mocks: { $apollo, clusterAgent }, + slots, + stubs: { GlTab }, + }), + ); }; - const findCreatedText = () => wrapper.find('[data-testid="cluster-agent-create-info"]').text(); - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findPaginationButtons = () => wrapper.find(GlKeysetPagination); - const findTokenCount = () => wrapper.find('[data-testid="cluster-agent-token-count"]').text(); + const findCreatedText = () => wrapper.findByTestId('cluster-agent-create-info').text(); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findPaginationButtons = () => wrapper.findComponent(GlKeysetPagination); + const findTokenCount = () => wrapper.findByTestId('cluster-agent-token-count').text(); + const findEESecurityTabSlot = () => wrapper.findByTestId('ee-security-tab'); afterEach(() => { wrapper.destroy(); @@ -87,7 +95,7 @@ describe('ClusterAgentShow', () => { }); it('renders token table', () => { - expect(wrapper.find(TokenTable).exists()).toBe(true); + expect(wrapper.findComponent(TokenTable).exists()).toBe(true); }); it('should not render pagination buttons when there are no additional pages', () => { @@ -188,8 +196,27 @@ describe('ClusterAgentShow', () => { }); it('displays an alert message', () => { - expect(wrapper.find(GlAlert).exists()).toBe(true); + expect(wrapper.findComponent(GlAlert).exists()).toBe(true); expect(wrapper.text()).toContain(ClusterAgentShow.i18n.loadingError); }); }); + + describe('ee-security-tab slot', () => { + it('does not display when a slot is not passed in', async () => { + createWrapperWithoutApollo({ clusterAgent: defaultClusterAgent }); + await nextTick(); + expect(findEESecurityTabSlot().exists()).toBe(false); + }); + + it('does display when a slot is passed in', async () => { + createWrapperWithoutApollo({ + clusterAgent: defaultClusterAgent, + slots: { + 'ee-security-tab': `Security Tab!`, + }, + }); + await nextTick(); + expect(findEESecurityTabSlot().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js index e2726b93ea5..41bd492148e 100644 --- a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js +++ b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js @@ -1,18 +1,20 @@ -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlSprintf } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; import RemoveClusterConfirmation from '~/clusters/components/remove_cluster_confirmation.vue'; import SplitButton from '~/vue_shared/components/split_button.vue'; describe('Remove cluster confirmation modal', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = ({ props = {}, stubs = {} } = {}) => { wrapper = mount(RemoveClusterConfirmation, { propsData: { clusterPath: 'clusterPath', clusterName: 'clusterName', ...props, }, + stubs, }); }; @@ -27,35 +29,44 @@ describe('Remove cluster confirmation modal', () => { }); describe('split button dropdown', () => { - const findModal = () => wrapper.find(GlModal).vm; - const findSplitButton = () => wrapper.find(SplitButton); + const findModal = () => wrapper.findComponent(GlModal); + const findSplitButton = () => wrapper.findComponent(SplitButton); beforeEach(() => { - createComponent({ clusterName: 'my-test-cluster' }); - jest.spyOn(findModal(), 'show').mockReturnValue(); + createComponent({ + props: { clusterName: 'my-test-cluster' }, + stubs: { GlSprintf, GlModal: stubComponent(GlModal) }, + }); + jest.spyOn(findModal().vm, 'show').mockReturnValue(); }); - it('opens modal with "cleanup" option', () => { + it('opens modal with "cleanup" option', async () => { findSplitButton().vm.$emit('remove-cluster-and-cleanup'); - return wrapper.vm.$nextTick().then(() => { - expect(findModal().show).toHaveBeenCalled(); - expect(wrapper.vm.confirmCleanup).toEqual(true); - }); + await wrapper.vm.$nextTick(); + + expect(findModal().vm.show).toHaveBeenCalled(); + expect(wrapper.vm.confirmCleanup).toEqual(true); + expect(findModal().html()).toContain( + 'To remove your integration and resources, type my-test-cluster to confirm:', + ); }); - it('opens modal without "cleanup" option', () => { + it('opens modal without "cleanup" option', async () => { findSplitButton().vm.$emit('remove-cluster'); - return wrapper.vm.$nextTick().then(() => { - expect(findModal().show).toHaveBeenCalled(); - expect(wrapper.vm.confirmCleanup).toEqual(false); - }); + await wrapper.vm.$nextTick(); + + expect(findModal().vm.show).toHaveBeenCalled(); + expect(wrapper.vm.confirmCleanup).toEqual(false); + expect(findModal().html()).toContain( + 'To remove your integration, type my-test-cluster to confirm:', + ); }); describe('with cluster management project', () => { beforeEach(() => { - createComponent({ hasManagementProject: true }); + createComponent({ props: { hasManagementProject: true } }); }); it('renders regular button instead', () => { 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 a548721588e..38f0e0ba2c4 100644 --- a/spec/frontend/clusters_list/components/agent_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js @@ -1,13 +1,12 @@ import { GlAlert, GlEmptyState, GlSprintf } from '@gitlab/ui'; import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; const emptyStateImage = '/path/to/image'; const projectPath = 'path/to/project'; -const agentDocsUrl = 'path/to/agentDocs'; -const installDocsUrl = 'path/to/installDocs'; -const getStartedDocsUrl = 'path/to/getStartedDocs'; -const integrationDocsUrl = 'path/to/integrationDocs'; +const multipleClustersDocsUrl = helpPagePath('user/project/clusters/multiple_kubernetes_clusters'); +const installDocsUrl = helpPagePath('administration/clusters/kas'); describe('AgentEmptyStateComponent', () => { let wrapper; @@ -18,14 +17,10 @@ describe('AgentEmptyStateComponent', () => { const provideData = { emptyStateImage, projectPath, - agentDocsUrl, - installDocsUrl, - getStartedDocsUrl, - integrationDocsUrl, }; const findConfigurationsAlert = () => wrapper.findComponent(GlAlert); - const findAgentDocsLink = () => wrapper.findByTestId('agent-docs-link'); + const findMultipleClustersDocsLink = () => wrapper.findByTestId('multiple-clusters-docs-link'); const findInstallDocsLink = () => wrapper.findByTestId('install-docs-link'); const findIntegrationButton = () => wrapper.findByTestId('integration-primary-button'); const findEmptyState = () => wrapper.findComponent(GlEmptyState); @@ -41,12 +36,11 @@ describe('AgentEmptyStateComponent', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); it('renders correct href attributes for the links', () => { - expect(findAgentDocsLink().attributes('href')).toBe(agentDocsUrl); + expect(findMultipleClustersDocsLink().attributes('href')).toBe(multipleClustersDocsUrl); expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl); }); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index e3b90584f29..a6d76b069cf 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; +import { GlLink, GlIcon } from '@gitlab/ui'; import AgentTable from '~/clusters_list/components/agent_table.vue'; import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -47,7 +47,6 @@ const propsData = { }, ], }; -const provideData = { integrationDocsUrl: 'path/to/integrationDocs' }; describe('AgentTable', () => { let wrapper; @@ -60,7 +59,7 @@ describe('AgentTable', () => { wrapper.findAllByTestId('cluster-agent-configuration-link').at(at); beforeEach(() => { - wrapper = mountExtended(AgentTable, { propsData, provide: provideData }); + wrapper = mountExtended(AgentTable, { propsData }); }); afterEach(() => { @@ -70,10 +69,6 @@ describe('AgentTable', () => { } }); - it('displays header button', () => { - expect(wrapper.find(GlButton).text()).toBe('Install a new GitLab Agent'); - }); - describe('agent table', () => { it.each` agentName | link | lineNumber diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js index 54d5ae94172..2dec7cdc973 100644 --- a/spec/frontend/clusters_list/components/agents_spec.js +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -14,7 +14,7 @@ localVue.use(VueApollo); describe('Agents', () => { let wrapper; - const propsData = { + const defaultProps = { defaultBranchName: 'default', }; const provideData = { @@ -22,12 +22,12 @@ describe('Agents', () => { kasAddress: 'kas.example.com', }; - const createWrapper = ({ agents = [], pageInfo = null, trees = [] }) => { + const createWrapper = ({ props = {}, agents = [], pageInfo = null, trees = [], count = 0 }) => { const provide = provideData; const apolloQueryResponse = { data: { project: { - clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] } }, + clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] }, count }, repository: { tree: { trees: { nodes: trees, pageInfo } } }, }, }, @@ -40,7 +40,10 @@ describe('Agents', () => { wrapper = shallowMount(Agents, { localVue, apolloProvider, - propsData, + propsData: { + ...defaultProps, + ...props, + }, provide: provideData, }); @@ -54,7 +57,6 @@ describe('Agents', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); @@ -81,6 +83,8 @@ describe('Agents', () => { }, ]; + const count = 2; + const trees = [ { name: 'agent-2', @@ -121,7 +125,7 @@ describe('Agents', () => { ]; beforeEach(() => { - return createWrapper({ agents, trees }); + return createWrapper({ agents, count, trees }); }); it('should render agent table', () => { @@ -133,6 +137,10 @@ describe('Agents', () => { expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); }); + it('should emit agents count to the parent component', () => { + expect(wrapper.emitted().onAgentsLoad).toEqual([[count]]); + }); + describe('when the agent has recently connected tokens', () => { it('should set agent status to active', () => { expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); @@ -180,6 +188,20 @@ describe('Agents', () => { it('should pass pageInfo to the pagination component', () => { expect(findPaginationButtons().props()).toMatchObject(pageInfo); }); + + describe('when limit is passed from the parent component', () => { + beforeEach(() => { + return createWrapper({ + props: { limit: 6 }, + agents, + pageInfo, + }); + }); + + it('should not render pagination buttons', () => { + expect(findPaginationButtons().exists()).toBe(false); + }); + }); }); }); @@ -234,7 +256,11 @@ describe('Agents', () => { }; beforeEach(() => { - wrapper = shallowMount(Agents, { mocks, propsData, provide: provideData }); + wrapper = shallowMount(Agents, { + mocks, + propsData: defaultProps, + provide: provideData, + }); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js new file mode 100644 index 00000000000..cb8303ca4b2 --- /dev/null +++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js @@ -0,0 +1,55 @@ +import { GlDropdown, GlDropdownItem } 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'; +import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '~/clusters_list/constants'; + +describe('ClustersActionsComponent', () => { + let wrapper; + + const newClusterPath = 'path/to/create/cluster'; + const addClusterPath = 'path/to/connect/existing/cluster'; + + const provideData = { + newClusterPath, + addClusterPath, + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link'); + const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link'); + const findConnectNewAgentLink = () => wrapper.findByTestId('connect-new-agent-link'); + + beforeEach(() => { + wrapper = shallowMountExtended(ClustersActions, { + provide: provideData, + directives: { + GlModalDirective: createMockDirective(), + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders actions menu', () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton); + }); + + it('renders a dropdown with 3 actions items', () => { + expect(findDropdownItems()).toHaveLength(3); + }); + + it('renders correct href attributes for the links', () => { + expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); + expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath); + }); + + it('renders correct modal id for the agent link', () => { + const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive'); + + expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); + }); +}); diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js new file mode 100644 index 00000000000..f7e1791d0f7 --- /dev/null +++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js @@ -0,0 +1,104 @@ +import { GlEmptyState, GlButton } 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 newClusterPath = '/path/to/connect/cluster'; +const emptyStateHelpText = 'empty state text'; +const canAddCluster = true; + +describe('ClustersEmptyStateComponent', () => { + let wrapper; + + const propsData = { + isChildComponent: false, + }; + + const provideData = { + clustersEmptyStateImage, + emptyStateHelpText: null, + newClusterPath, + }; + + const entryData = { + canAddCluster, + }; + + const findButton = () => wrapper.findComponent(GlButton); + const findEmptyStateText = () => wrapper.findByTestId('clusters-empty-state-text'); + + beforeEach(() => { + wrapper = shallowMountExtended(ClustersEmptyState, { + store: ClusterStore(entryData), + propsData, + provide: provideData, + stubs: { GlEmptyState }, + }); + }); + + 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(() => { + propsData.isChildComponent = true; + wrapper = shallowMountExtended(ClustersEmptyState, { + store: ClusterStore(entryData), + propsData, + provide: provideData, + }); + }); + + afterEach(() => { + propsData.isChildComponent = false; + }); + + it('should not render the action button', () => { + expect(findButton().exists()).toBe(false); + }); + }); + + describe('when the help text is provided', () => { + beforeEach(() => { + provideData.emptyStateHelpText = emptyStateHelpText; + wrapper = shallowMountExtended(ClustersEmptyState, { + store: ClusterStore(entryData), + propsData, + provide: provideData, + }); + }); + + it('should show the empty state text', () => { + expect(findEmptyStateText().text()).toBe(emptyStateHelpText); + }); + }); + + describe('when the user cannot add clusters', () => { + entryData.canAddCluster = false; + beforeEach(() => { + wrapper = shallowMountExtended(ClustersEmptyState, { + store: ClusterStore(entryData), + propsData, + provide: provideData, + stubs: { GlEmptyState }, + }); + }); + it('should disable the button', () => { + expect(findButton().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/clusters_main_view_spec.js b/spec/frontend/clusters_list/components/clusters_main_view_spec.js new file mode 100644 index 00000000000..c2233e5d39c --- /dev/null +++ b/spec/frontend/clusters_list/components/clusters_main_view_spec.js @@ -0,0 +1,82 @@ +import { GlTabs, GlTab } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ClustersMainView from '~/clusters_list/components/clusters_main_view.vue'; +import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue'; +import { + AGENT, + CERTIFICATE_BASED, + CLUSTERS_TABS, + MAX_CLUSTERS_LIST, + MAX_LIST_COUNT, +} from '~/clusters_list/constants'; + +const defaultBranchName = 'default-branch'; + +describe('ClustersMainViewComponent', () => { + let wrapper; + + const propsData = { + defaultBranchName, + }; + + beforeEach(() => { + wrapper = shallowMountExtended(ClustersMainView, { + propsData, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findTabs = () => wrapper.findComponent(GlTabs); + const findAllTabs = () => wrapper.findAllComponents(GlTab); + const findGlTabAtIndex = (index) => findAllTabs().at(index); + const findComponent = () => wrapper.findByTestId('clusters-tab-component'); + const findModal = () => wrapper.findComponent(InstallAgentModal); + + it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', () => { + expect(findTabs().exists()).toBe(true); + expect(findTabs().props('syncActiveTabWithQueryParams')).toBe(true); + }); + + it('renders correct number of tabs', () => { + expect(findAllTabs()).toHaveLength(CLUSTERS_TABS.length); + }); + + it('passes child-component param to the component', () => { + expect(findComponent().props('defaultBranchName')).toBe(defaultBranchName); + }); + + it('passes correct max-agents param to the modal', () => { + expect(findModal().props('maxAgents')).toBe(MAX_CLUSTERS_LIST); + }); + + describe('tabs', () => { + it.each` + tabTitle | queryParamValue | lineNumber + ${'All'} | ${'all'} | ${0} + ${'Agent'} | ${AGENT} | ${1} + ${'Certificate based'} | ${CERTIFICATE_BASED} | ${2} + `( + 'renders correct tab title and query param value', + ({ tabTitle, queryParamValue, lineNumber }) => { + expect(findGlTabAtIndex(lineNumber).attributes('title')).toBe(tabTitle); + expect(findGlTabAtIndex(lineNumber).props('queryParamValue')).toBe(queryParamValue); + }, + ); + }); + + describe('when the child component emits the tab change event', () => { + beforeEach(() => { + findComponent().vm.$emit('changeTab', AGENT); + }); + it('changes the tab', () => { + expect(findTabs().attributes('value')).toBe('1'); + }); + + it('passes correct max-agents param to the modal', () => { + expect(findModal().props('maxAgents')).toBe(MAX_LIST_COUNT); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index 941a3adb625..a34202c789d 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -8,6 +8,7 @@ import * as Sentry from '@sentry/browser'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Clusters from '~/clusters_list/components/clusters.vue'; +import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state.vue'; import ClusterStore from '~/clusters_list/store'; import axios from '~/lib/utils/axios_utils'; import { apiData } from '../mock_data'; @@ -18,26 +19,38 @@ describe('Clusters', () => { let wrapper; const endpoint = 'some/endpoint'; + const totalClustersNumber = 6; + const clustersEmptyStateImage = 'path/to/svg'; + const emptyStateHelpText = null; + const newClusterPath = '/path/to/new/cluster'; const entryData = { endpoint, imgTagsAwsText: 'AWS Icon', imgTagsDefaultText: 'Default Icon', imgTagsGcpText: 'GCP Icon', + totalClusters: totalClustersNumber, }; - const findLoader = () => wrapper.find(GlLoadingIcon); - const findPaginatedButtons = () => wrapper.find(GlPagination); - const findTable = () => wrapper.find(GlTable); + const provideData = { + clustersEmptyStateImage, + emptyStateHelpText, + newClusterPath, + }; + + const findLoader = () => wrapper.findComponent(GlLoadingIcon); + const findPaginatedButtons = () => wrapper.findComponent(GlPagination); + const findTable = () => wrapper.findComponent(GlTable); const findStatuses = () => findTable().findAll('.js-status'); + const findEmptyState = () => wrapper.findComponent(ClustersEmptyState); const mockPollingApi = (response, body, header) => { mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header); }; - const mountWrapper = () => { + const createWrapper = ({ propsData = {} }) => { store = ClusterStore(entryData); - wrapper = mount(Clusters, { store }); + wrapper = mount(Clusters, { propsData, provide: provideData, store, stubs: { GlTable } }); return axios.waitForAll(); }; @@ -57,7 +70,7 @@ describe('Clusters', () => { mock = new MockAdapter(axios); mockPollingApi(200, apiData, paginationHeader()); - return mountWrapper(); + return createWrapper({}); }); afterEach(() => { @@ -70,7 +83,6 @@ describe('Clusters', () => { describe('when data is loading', () => { beforeEach(() => { wrapper.vm.$store.state.loadingClusters = true; - return wrapper.vm.$nextTick(); }); it('displays a loader instead of the table while loading', () => { @@ -79,23 +91,29 @@ describe('Clusters', () => { }); }); - it('displays a table component', () => { - expect(findTable().exists()).toBe(true); + describe('when clusters are present', () => { + it('displays a table component', () => { + expect(findTable().exists()).toBe(true); + }); }); - it('renders the correct table headers', () => { - const tableHeaders = wrapper.vm.fields; - const headers = findTable().findAll('th'); - - expect(headers.length).toBe(tableHeaders.length); - - tableHeaders.forEach((headerText, i) => - expect(headers.at(i).text()).toEqual(headerText.label), - ); + describe('when there are no clusters', () => { + beforeEach(() => { + wrapper.vm.$store.state.totalClusters = 0; + }); + it('should render empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); }); - it('should stack on smaller devices', () => { - expect(findTable().classes()).toContain('b-table-stacked-md'); + describe('when is loaded as a child component', () => { + beforeEach(() => { + createWrapper({ limit: 6 }); + }); + + it("shouldn't render pagination buttons", () => { + expect(findPaginatedButtons().exists()).toBe(false); + }); }); }); @@ -240,7 +258,7 @@ describe('Clusters', () => { beforeEach(() => { mockPollingApi(200, apiData, paginationHeader(totalFirstPage, perPage, 1)); - return mountWrapper(); + return createWrapper({}); }); it('should load to page 1 with header values', () => { diff --git a/spec/frontend/clusters_list/components/clusters_view_all_spec.js b/spec/frontend/clusters_list/components/clusters_view_all_spec.js new file mode 100644 index 00000000000..6ef56beddee --- /dev/null +++ b/spec/frontend/clusters_list/components/clusters_view_all_spec.js @@ -0,0 +1,243 @@ +import { GlCard, GlLoadingIcon, GlButton, GlSprintf, GlBadge } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +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'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const addClusterPath = '/path/to/add/cluster'; +const defaultBranchName = 'default-branch'; + +describe('ClustersViewAllComponent', () => { + let wrapper; + + const event = { + preventDefault: jest.fn(), + }; + + const propsData = { + defaultBranchName, + }; + + const provideData = { + addClusterPath, + }; + + const entryData = { + loadingClusters: false, + totalClusters: 0, + }; + + const findCards = () => wrapper.findAllComponents(GlCard); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAgentsComponent = () => wrapper.findComponent(Agents); + const findClustersComponent = () => wrapper.findComponent(Clusters); + 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 createStore = (initialState) => + new Vuex.Store({ + state: initialState, + }); + + const createWrapper = ({ initialState }) => { + wrapper = shallowMountExtended(ClustersViewAll, { + localVue, + store: createStore(initialState), + propsData, + provide: provideData, + directives: { + GlModalDirective: createMockDirective(), + }, + stubs: { GlCard, GlSprintf }, + }); + }; + + beforeEach(() => { + createWrapper({ initialState: entryData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when agents and clusters are not loaded', () => { + const initialState = { + loadingClusters: true, + totalClusters: 0, + }; + beforeEach(() => { + createWrapper({ initialState }); + }); + + it('should show the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('when both agents and clusters are loaded', () => { + beforeEach(() => { + findAgentsComponent().vm.$emit('onAgentsLoad', 6); + }); + + it("shouldn't show the loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('should make content visible', () => { + expect(findCardsContainer().isVisible()).toBe(true); + }); + + it('should render 2 cards', () => { + expect(findCards().length).toBe(2); + }); + }); + + describe('agents card', () => { + it('should show recommended badge', () => { + expect(findRecommendedBadge().exists()).toBe(true); + }); + + it('should render Agents component', () => { + expect(findAgentsComponent().exists()).toBe(true); + }); + + it('should pass the limit prop', () => { + expect(findAgentsComponent().props('limit')).toBe(MAX_CLUSTERS_LIST); + }); + + it('should pass the default-branch-name prop', () => { + expect(findAgentsComponent().props('defaultBranchName')).toBe(defaultBranchName); + }); + + describe('when there are no agents', () => { + it('should show the empty title', () => { + expect(findAgentCardTitle().text()).toBe(AGENT_CARD_INFO.emptyTitle); + }); + + it('should show install new Agent button in the footer', () => { + expect(findFooterButton(0).exists()).toBe(true); + }); + + 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', () => { + const findFooterLink = () => wrapper.findByTestId('agents-tab-footer-link'); + const agentsNumber = 7; + + beforeEach(() => { + findAgentsComponent().vm.$emit('onAgentsLoad', agentsNumber); + }); + + it('should show the correct title', () => { + expect(findAgentCardTitle().text()).toBe( + sprintf(AGENT_CARD_INFO.title, { number: MAX_CLUSTERS_LIST, total: agentsNumber }), + ); + }); + + it('should show the link to the Agents tab in the footer', () => { + expect(findFooterLink().exists()).toBe(true); + expect(findFooterLink().text()).toBe( + sprintf(AGENT_CARD_INFO.footerText, { number: agentsNumber }), + ); + expect(findFooterLink().attributes('href')).toBe(`?tab=${AGENT}`); + }); + + describe('when clicking on the footer link', () => { + beforeEach(() => { + findFooterLink().vm.$emit('click', event); + }); + + it('should trigger tab change', () => { + expect(wrapper.emitted('changeTab')).toEqual([[AGENT]]); + }); + }); + }); + }); + + describe('clusters tab', () => { + it('should pass the limit prop', () => { + 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 Agent button in the footer', () => { + expect(findFooterButton(1).exists()).toBe(true); + }); + + it('should render correct href for the button in the footer', () => { + expect(findFooterButton(1).attributes('href')).toBe(addClusterPath); + }); + }); + + describe('when the clusters are present', () => { + const findFooterLink = () => wrapper.findByTestId('clusters-tab-footer-link'); + + const clustersNumber = 7; + const initialState = { + loadingClusters: false, + totalClusters: clustersNumber, + }; + + beforeEach(() => { + createWrapper({ initialState }); + }); + + it('should show the correct title', () => { + expect(findClustersCardTitle().text()).toBe( + sprintf(CERTIFICATE_BASED_CARD_INFO.title, { + number: MAX_CLUSTERS_LIST, + total: clustersNumber, + }), + ); + }); + + it('should show the link to the Clusters tab in the footer', () => { + expect(findFooterLink().exists()).toBe(true); + expect(findFooterLink().text()).toBe( + sprintf(CERTIFICATE_BASED_CARD_INFO.footerText, { number: clustersNumber }), + ); + }); + + describe('when clicking on the footer link', () => { + beforeEach(() => { + findFooterLink().vm.$emit('click', event); + }); + + it('should trigger tab change', () => { + expect(wrapper.emitted('changeTab')).toEqual([[CERTIFICATE_BASED]]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js index 98ca5e05b3f..6c2ea45b99b 100644 --- a/spec/frontend/clusters_list/components/install_agent_modal_spec.js +++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js @@ -3,7 +3,8 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue'; -import { I18N_INSTALL_AGENT_MODAL } from '~/clusters_list/constants'; +import { I18N_INSTALL_AGENT_MODAL, MAX_LIST_COUNT } from '~/clusters_list/constants'; +import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql'; import createAgentMutation from '~/clusters_list/graphql/mutations/create_agent.mutation.graphql'; import createAgentTokenMutation from '~/clusters_list/graphql/mutations/create_agent_token.mutation.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -14,12 +15,17 @@ import { createAgentErrorResponse, createAgentTokenResponse, createAgentTokenErrorResponse, + getAgentResponse, } from '../mocks/apollo'; import ModalStub from '../stubs'; const localVue = createLocalVue(); localVue.use(VueApollo); +const projectPath = 'path/to/project'; +const defaultBranchName = 'default'; +const maxAgents = MAX_LIST_COUNT; + describe('InstallAgentModal', () => { let wrapper; let apolloProvider; @@ -45,10 +51,15 @@ describe('InstallAgentModal', () => { const createWrapper = () => { const provide = { - projectPath: 'path/to/project', + projectPath, kasAddress: 'kas.example.com', }; + const propsData = { + defaultBranchName, + maxAgents, + }; + wrapper = shallowMount(InstallAgentModal, { attachTo: document.body, stubs: { @@ -57,11 +68,26 @@ describe('InstallAgentModal', () => { localVue, apolloProvider, provide, + propsData, + }); + }; + + const writeQuery = () => { + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getAgentsQuery, + variables: { + projectPath, + defaultBranchName, + first: MAX_LIST_COUNT, + last: null, + }, + data: getAgentResponse.data, }); }; const mockSelectedAgentResponse = () => { createWrapper(); + writeQuery(); wrapper.vm.setAgentName('agent-name'); findActionButton().vm.$emit('click'); @@ -95,7 +121,7 @@ describe('InstallAgentModal', () => { it('renders a disabled next button', () => { expect(findActionButton().isVisible()).toBe(true); - expect(findActionButton().text()).toBe(i18n.next); + expect(findActionButton().text()).toBe(i18n.registerAgentButton); expectDisabledAttribute(findActionButton(), true); }); }); @@ -126,7 +152,7 @@ describe('InstallAgentModal', () => { it('creates an agent and token', () => { expect(createAgentHandler).toHaveBeenCalledWith({ - input: { name: 'agent-name', projectPath: 'path/to/project' }, + input: { name: 'agent-name', projectPath }, }); expect(createAgentTokenHandler).toHaveBeenCalledWith({ @@ -134,9 +160,9 @@ describe('InstallAgentModal', () => { }); }); - it('renders a done button', () => { + it('renders a close button', () => { expect(findActionButton().isVisible()).toBe(true); - expect(findActionButton().text()).toBe(i18n.done); + expect(findActionButton().text()).toBe(i18n.close); expectDisabledAttribute(findActionButton(), false); }); diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js index 27b71a0d4b5..1a7ef84a6d9 100644 --- a/spec/frontend/clusters_list/mocks/apollo.js +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -1,8 +1,29 @@ +const agent = { + id: 'agent-id', + name: 'agent-name', + webPath: 'agent-webPath', +}; +const token = { + id: 'token-id', + lastUsedAt: null, +}; +const tokens = { + nodes: [token], +}; +const pageInfo = { + endCursor: '', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', +}; +const count = 1; + export const createAgentResponse = { data: { createClusterAgent: { clusterAgent: { - id: 'agent-id', + ...agent, + tokens, }, errors: [], }, @@ -13,7 +34,8 @@ export const createAgentErrorResponse = { data: { createClusterAgent: { clusterAgent: { - id: 'agent-id', + ...agent, + tokens, }, errors: ['could not create agent'], }, @@ -23,9 +45,7 @@ export const createAgentErrorResponse = { export const createAgentTokenResponse = { data: { clusterAgentTokenCreate: { - token: { - id: 'token-id', - }, + token, secret: 'mock-agent-token', errors: [], }, @@ -35,11 +55,22 @@ export const createAgentTokenResponse = { export const createAgentTokenErrorResponse = { data: { clusterAgentTokenCreate: { - token: { - id: 'token-id', - }, + token, secret: 'mock-agent-token', errors: ['could not create agent token'], }, }, }; + +export const getAgentResponse = { + data: { + project: { + clusterAgents: { nodes: [{ ...agent, tokens }], pageInfo, count }, + repository: { + tree: { + trees: { nodes: [{ ...agent, path: null }], pageInfo }, + }, + }, + }, + }, +}; diff --git a/spec/frontend/clusters_list/store/mutations_spec.js b/spec/frontend/clusters_list/store/mutations_spec.js index c0fe634a703..ae264eee449 100644 --- a/spec/frontend/clusters_list/store/mutations_spec.js +++ b/spec/frontend/clusters_list/store/mutations_spec.js @@ -26,7 +26,7 @@ describe('Admin statistics panel mutations', () => { expect(state.clusters).toBe(apiData.clusters); expect(state.clustersPerPage).toBe(paginationInformation.perPage); expect(state.hasAncestorClusters).toBe(apiData.has_ancestor_clusters); - expect(state.totalCulsters).toBe(paginationInformation.total); + expect(state.totalClusters).toBe(paginationInformation.total); }); }); @@ -57,4 +57,12 @@ describe('Admin statistics panel mutations', () => { expect(state.page).toBe(123); }); }); + + describe(`${types.SET_CLUSTERS_PER_PAGE}`, () => { + it('changes clustersPerPage value', () => { + mutations[types.SET_CLUSTERS_PER_PAGE](state, 123); + + expect(state.clustersPerPage).toBe(123); + }); + }); }); diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index 17f7be9d1d7..c376b58cc72 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -1,4 +1,4 @@ -import { GlEmptyState, GlLoadingIcon, GlModal, GlTable } from '@gitlab/ui'; +import { GlEmptyState, GlLoadingIcon, GlModal, GlTableLite } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import fixture from 'test_fixtures/pipelines/pipelines.json'; @@ -6,8 +6,13 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; import PipelinesTable from '~/commit/pipelines/pipelines_table.vue'; +import { TOAST_MESSAGE } from '~/pipelines/constants'; import axios from '~/lib/utils/axios_utils'; +const $toast = { + show: jest.fn(), +}; + describe('Pipelines table in Commits and Merge requests', () => { let wrapper; let pipeline; @@ -17,7 +22,7 @@ describe('Pipelines table in Commits and Merge requests', () => { const findRunPipelineBtnMobile = () => wrapper.findByTestId('run_pipeline_button_mobile'); const findLoadingState = () => wrapper.findComponent(GlLoadingIcon); const findEmptyState = () => wrapper.findComponent(GlEmptyState); - const findTable = () => wrapper.findComponent(GlTable); + const findTable = () => wrapper.findComponent(GlTableLite); const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); const findModal = () => wrapper.findComponent(GlModal); @@ -30,6 +35,9 @@ describe('Pipelines table in Commits and Merge requests', () => { errorStateSvgPath: 'foo', ...props, }, + mocks: { + $toast, + }, }), ); }; @@ -178,6 +186,12 @@ describe('Pipelines table in Commits and Merge requests', () => { await waitForPromises(); }); + it('displays a toast message during pipeline creation', async () => { + await findRunPipelineBtn().trigger('click'); + + expect($toast.show).toHaveBeenCalledWith(TOAST_MESSAGE); + }); + it('on desktop, shows a loading button', async () => { await findRunPipelineBtn().trigger('click'); diff --git a/spec/frontend/confirm_modal_spec.js b/spec/frontend/confirm_modal_spec.js index 8a12ff3a01f..5e5345cbd2b 100644 --- a/spec/frontend/confirm_modal_spec.js +++ b/spec/frontend/confirm_modal_spec.js @@ -72,7 +72,7 @@ describe('ConfirmModal', () => { it('starts with only JsHooks', () => { expect(findJsHooks()).toHaveLength(buttons.length); - expect(findModal()).not.toExist(); + expect(findModal()).toBe(null); }); describe('when button clicked', () => { @@ -87,7 +87,7 @@ describe('ConfirmModal', () => { describe('GlModal', () => { it('is rendered', () => { - expect(findModal()).toExist(); + expect(findModal()).not.toBe(null); expect(modalIsHidden()).toBe(false); }); diff --git a/spec/frontend/content_editor/components/content_editor_alert_spec.js b/spec/frontend/content_editor/components/content_editor_alert_spec.js new file mode 100644 index 00000000000..2ddcd8f024e --- /dev/null +++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js @@ -0,0 +1,60 @@ +import { GlAlert } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; +import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; +import { createTestEditor, emitEditorEvent } from '../test_utils'; + +describe('content_editor/components/content_editor_alert', () => { + let wrapper; + let tiptapEditor; + + const findErrorAlert = () => wrapper.findComponent(GlAlert); + + const createWrapper = async () => { + tiptapEditor = createTestEditor(); + + wrapper = shallowMountExtended(ContentEditorAlert, { + provide: { + tiptapEditor, + }, + stubs: { + EditorStateObserver, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + variant | message + ${'danger'} | ${'An error occurred'} + ${'warning'} | ${'A warning'} + `( + 'renders error when content editor emits an error event for variant: $variant', + async ({ message, variant }) => { + createWrapper(); + + await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } }); + + expect(findErrorAlert().text()).toBe(message); + expect(findErrorAlert().attributes().variant).toBe(variant); + }, + ); + + it('allows dismissing the error', async () => { + const message = 'error message'; + + createWrapper(); + + await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } }); + + findErrorAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findErrorAlert().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/content_editor/components/content_editor_error_spec.js b/spec/frontend/content_editor/components/content_editor_error_spec.js deleted file mode 100644 index 8723fb5a338..00000000000 --- a/spec/frontend/content_editor/components/content_editor_error_spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import { nextTick } from 'vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ContentEditorError from '~/content_editor/components/content_editor_error.vue'; -import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; -import { createTestEditor, emitEditorEvent } from '../test_utils'; - -describe('content_editor/components/content_editor_error', () => { - let wrapper; - let tiptapEditor; - - const findErrorAlert = () => wrapper.findComponent(GlAlert); - - const createWrapper = async () => { - tiptapEditor = createTestEditor(); - - wrapper = shallowMountExtended(ContentEditorError, { - provide: { - tiptapEditor, - }, - stubs: { - EditorStateObserver, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders error when content editor emits an error event', async () => { - const error = 'error message'; - - createWrapper(); - - await emitEditorEvent({ tiptapEditor, event: 'error', params: { error } }); - - expect(findErrorAlert().text()).toBe(error); - }); - - it('allows dismissing the error', async () => { - const error = 'error message'; - - createWrapper(); - - await emitEditorEvent({ tiptapEditor, event: 'error', params: { error } }); - - findErrorAlert().vm.$emit('dismiss'); - - await nextTick(); - - expect(findErrorAlert().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 3d1ef03083d..9a772c41e52 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -3,7 +3,7 @@ import { EditorContent } from '@tiptap/vue-2'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditor from '~/content_editor/components/content_editor.vue'; -import ContentEditorError from '~/content_editor/components/content_editor_error.vue'; +import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue'; @@ -111,10 +111,10 @@ describe('ContentEditor', () => { ]); }); - it('renders content_editor_error component', () => { + it('renders content_editor_alert component', () => { createWrapper(); - expect(wrapper.findComponent(ContentEditorError).exists()).toBe(true); + expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true); }); describe('when loading content', () => { diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js index e48f59f6d9c..6017a145a87 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -11,13 +11,13 @@ jest.mock('prosemirror-tables'); describe('content/components/wrappers/table_cell_base', () => { let wrapper; let editor; - let getPos; + let node; const createWrapper = async (propsData = { cellType: 'td' }) => { wrapper = shallowMountExtended(TableCellBaseWrapper, { propsData: { editor, - getPos, + node, ...propsData, }, }); @@ -36,7 +36,7 @@ describe('content/components/wrappers/table_cell_base', () => { const setCurrentPositionInCell = () => { const { $cursor } = editor.state.selection; - getPos.mockReturnValue($cursor.pos - $cursor.parentOffset - 1); + jest.spyOn($cursor, 'node').mockReturnValue(node); }; const mockDropdownHide = () => { /* @@ -48,7 +48,7 @@ describe('content/components/wrappers/table_cell_base', () => { }; beforeEach(() => { - getPos = jest.fn(); + node = {}; editor = createTestEditor({}); }); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js index 5d26c44ba03..2aefbc77545 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js @@ -6,19 +6,19 @@ import { createTestEditor } from '../../test_utils'; describe('content/components/wrappers/table_cell_body', () => { let wrapper; let editor; - let getPos; + let node; const createWrapper = async () => { wrapper = shallowMount(TableCellBodyWrapper, { propsData: { editor, - getPos, + node, }, }); }; beforeEach(() => { - getPos = jest.fn(); + node = {}; editor = createTestEditor({}); }); @@ -30,7 +30,7 @@ describe('content/components/wrappers/table_cell_body', () => { createWrapper(); expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({ editor, - getPos, + node, cellType: 'td', }); }); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js index e561191418d..e48df8734a6 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js @@ -6,19 +6,19 @@ import { createTestEditor } from '../../test_utils'; describe('content/components/wrappers/table_cell_header', () => { let wrapper; let editor; - let getPos; + let node; const createWrapper = async () => { wrapper = shallowMount(TableCellHeaderWrapper, { propsData: { editor, - getPos, + node, }, }); }; beforeEach(() => { - getPos = jest.fn(); + node = {}; editor = createTestEditor({}); }); @@ -30,7 +30,7 @@ describe('content/components/wrappers/table_cell_header', () => { createWrapper(); expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({ editor, - getPos, + node, cellType: 'th', }); }); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index d4f05a25bd6..d2d2cd98a78 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -74,10 +74,10 @@ describe('content_editor/extensions/attachment', () => { }); it.each` - eventType | propName | eventData | output - ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [attachmentFile] } }} | ${true} - ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [] } }} | ${undefined} - ${'drop'} | ${'handleDrop'} | ${{ dataTransfer: { files: [attachmentFile] } }} | ${true} + eventType | propName | eventData | output + ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { getData: jest.fn(), files: [attachmentFile] } }} | ${true} + ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { getData: jest.fn(), files: [] } }} | ${undefined} + ${'drop'} | ${'handleDrop'} | ${{ dataTransfer: { getData: jest.fn(), files: [attachmentFile] } }} | ${true} `('handles $eventType properly', ({ eventType, propName, eventData, output }) => { const event = Object.assign(new Event(eventType), eventData); const handled = tiptapEditor.view.someProp(propName, (eventHandler) => { @@ -157,11 +157,11 @@ describe('content_editor/extensions/attachment', () => { }); }); - it('emits an error event that includes an error message', (done) => { + it('emits an alert event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: imageFile }); - tiptapEditor.on('error', ({ error }) => { - expect(error).toBe('An error occurred while uploading the image. Please try again.'); + tiptapEditor.on('alert', ({ message }) => { + expect(message).toBe('An error occurred while uploading the image. Please try again.'); done(); }); }); @@ -233,11 +233,11 @@ describe('content_editor/extensions/attachment', () => { }); }); - it('emits an error event that includes an error message', (done) => { + it('emits an alert event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - tiptapEditor.on('error', ({ error }) => { - expect(error).toBe('An error occurred while uploading the file. Please try again.'); + tiptapEditor.on('alert', ({ message }) => { + expect(message).toBe('An error occurred while uploading the file. Please try again.'); done(); }); }); diff --git a/spec/frontend/content_editor/extensions/blockquote_spec.js b/spec/frontend/content_editor/extensions/blockquote_spec.js index c5b5044352d..1644647ba69 100644 --- a/spec/frontend/content_editor/extensions/blockquote_spec.js +++ b/spec/frontend/content_editor/extensions/blockquote_spec.js @@ -1,19 +1,37 @@ -import { multilineInputRegex } from '~/content_editor/extensions/blockquote'; +import Blockquote from '~/content_editor/extensions/blockquote'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; describe('content_editor/extensions/blockquote', () => { - describe.each` - input | matches - ${'>>> '} | ${true} - ${' >>> '} | ${true} - ${'\t>>> '} | ${true} - ${'>> '} | ${false} - ${'>>>x '} | ${false} - ${'> '} | ${false} - `('multilineInputRegex', ({ input, matches }) => { - it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { - const match = new RegExp(multilineInputRegex).test(input); + let tiptapEditor; + let doc; + let p; + let blockquote; - expect(match).toBe(matches); - }); + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Blockquote] }); + + ({ + builders: { doc, p, blockquote }, + } = createDocBuilder({ + tiptapEditor, + names: { + blockquote: { nodeType: Blockquote.name }, + }, + })); + }); + + it.each` + input | insertedNode + ${'>>> '} | ${() => blockquote({ multiline: true }, p())} + ${'> '} | ${() => blockquote(p())} + ${' >>> '} | ${() => blockquote({ multiline: true }, p())} + ${'>> '} | ${() => p()} + ${'>>>x '} | ${() => p()} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const expectedDoc = doc(insertedNode()); + + triggerNodeInputRule({ tiptapEditor, inputRuleText: input }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); }); diff --git a/spec/frontend/content_editor/extensions/emoji_spec.js b/spec/frontend/content_editor/extensions/emoji_spec.js index c1b8dc9bdbb..939c46e991a 100644 --- a/spec/frontend/content_editor/extensions/emoji_spec.js +++ b/spec/frontend/content_editor/extensions/emoji_spec.js @@ -1,6 +1,6 @@ import { initEmojiMock } from 'helpers/emoji'; import Emoji from '~/content_editor/extensions/emoji'; -import { createTestEditor, createDocBuilder } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; describe('content_editor/extensions/emoji', () => { let tiptapEditor; @@ -28,18 +28,16 @@ describe('content_editor/extensions/emoji', () => { describe('when typing a valid emoji input rule', () => { it('inserts an emoji node', () => { - const { view } = tiptapEditor; - const { selection } = view.state; const expectedDoc = doc( p( ' ', emoji({ moji: '❤', name: 'heart', title: 'heavy black heart', unicodeVersion: '1.1' }), ), ); - // Triggers the event handler that input rules listen to - view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, ':heart:')); - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + triggerNodeInputRule({ tiptapEditor, inputRuleText: ':heart:' }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); }); diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js new file mode 100644 index 00000000000..517f6947b9a --- /dev/null +++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js @@ -0,0 +1,30 @@ +import Frontmatter from '~/content_editor/extensions/frontmatter'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; + +describe('content_editor/extensions/frontmatter', () => { + let tiptapEditor; + let doc; + let p; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Frontmatter] }); + + ({ + builders: { doc, p }, + } = createDocBuilder({ + tiptapEditor, + names: { + frontmatter: { nodeType: Frontmatter.name }, + }, + })); + }); + + it('does not insert a frontmatter block when executing code block input rule', () => { + const expectedDoc = doc(p('')); + const inputRuleText = '``` '; + + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/horizontal_rule_spec.js b/spec/frontend/content_editor/extensions/horizontal_rule_spec.js index a1bc7f0e8ed..322c04a42e1 100644 --- a/spec/frontend/content_editor/extensions/horizontal_rule_spec.js +++ b/spec/frontend/content_editor/extensions/horizontal_rule_spec.js @@ -1,20 +1,39 @@ -import { hrInputRuleRegExp } from '~/content_editor/extensions/horizontal_rule'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; describe('content_editor/extensions/horizontal_rule', () => { - describe.each` - input | matches - ${'---'} | ${true} - ${'--'} | ${false} - ${'---x'} | ${false} - ${' ---x'} | ${false} - ${' --- '} | ${false} - ${'x---x'} | ${false} - ${'x---'} | ${false} - `('hrInputRuleRegExp', ({ input, matches }) => { - it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { - const match = new RegExp(hrInputRuleRegExp).test(input); + let tiptapEditor; + let doc; + let p; + let horizontalRule; - expect(match).toBe(matches); - }); + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [HorizontalRule] }); + + ({ + builders: { doc, p, horizontalRule }, + } = createDocBuilder({ + tiptapEditor, + names: { + horizontalRule: { nodeType: HorizontalRule.name }, + }, + })); + }); + + it.each` + input | insertedNodes + ${'---'} | ${() => [p(), horizontalRule()]} + ${'--'} | ${() => [p()]} + ${'---x'} | ${() => [p()]} + ${' ---x'} | ${() => [p()]} + ${' --- '} | ${() => [p()]} + ${'x---x'} | ${() => [p()]} + ${'x---'} | ${() => [p()]} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNodes }) => { + const expectedDoc = doc(...insertedNodes()); + + triggerNodeInputRule({ tiptapEditor, inputRuleText: input }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); }); diff --git a/spec/frontend/content_editor/extensions/inline_diff_spec.js b/spec/frontend/content_editor/extensions/inline_diff_spec.js index 63cdf665e7f..99c559a20b1 100644 --- a/spec/frontend/content_editor/extensions/inline_diff_spec.js +++ b/spec/frontend/content_editor/extensions/inline_diff_spec.js @@ -1,27 +1,43 @@ -import { inputRegexAddition, inputRegexDeletion } from '~/content_editor/extensions/inline_diff'; +import InlineDiff from '~/content_editor/extensions/inline_diff'; +import { createTestEditor, createDocBuilder, triggerMarkInputRule } from '../test_utils'; describe('content_editor/extensions/inline_diff', () => { - describe.each` - inputRegex | description | input | matches - ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+world+}'} | ${true} - ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+ world +}'} | ${true} - ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello {+ world+}'} | ${true} - ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello world +}'} | ${true} - ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello with \nnewline+}'} | ${false} - ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+open only'} | ${false} - ${inputRegexAddition} | ${'inputRegexAddition'} | ${'close only+}'} | ${false} - ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{-world-}'} | ${true} - ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{- world -}'} | ${true} - ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello {- world-}'} | ${true} - ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-hello world -}'} | ${true} - ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{+hello with \nnewline+}'} | ${false} - ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-open only'} | ${false} - ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'close only-}'} | ${false} - `('$description', ({ inputRegex, input, matches }) => { - it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { - const match = new RegExp(inputRegex).test(input); + let tiptapEditor; + let doc; + let p; + let inlineDiff; - expect(match).toBe(matches); - }); + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [InlineDiff] }); + ({ + builders: { doc, p, inlineDiff }, + } = createDocBuilder({ + tiptapEditor, + names: { + inlineDiff: { markType: InlineDiff.name }, + }, + })); + }); + + it.each` + input | insertedNode + ${'hello{+world+}'} | ${() => p('hello', inlineDiff('world'))} + ${'hello{+ world +}'} | ${() => p('hello', inlineDiff(' world '))} + ${'{+hello with \nnewline+}'} | ${() => p('{+hello with newline+}')} + ${'{+open only'} | ${() => p('{+open only')} + ${'close only+}'} | ${() => p('close only+}')} + ${'hello{-world-}'} | ${() => p('hello', inlineDiff({ type: 'deletion' }, 'world'))} + ${'hello{- world -}'} | ${() => p('hello', inlineDiff({ type: 'deletion' }, ' world '))} + ${'hello {- world-}'} | ${() => p('hello ', inlineDiff({ type: 'deletion' }, ' world'))} + ${'{-hello world -}'} | ${() => p(inlineDiff({ type: 'deletion' }, 'hello world '))} + ${'{-hello with \nnewline-}'} | ${() => p('{-hello with newline-}')} + ${'{-open only'} | ${() => p('{-open only')} + ${'close only-}'} | ${() => p('close only-}')} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const expectedDoc = doc(insertedNode()); + + triggerMarkInputRule({ tiptapEditor, inputRuleText: input }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); }); diff --git a/spec/frontend/content_editor/extensions/link_spec.js b/spec/frontend/content_editor/extensions/link_spec.js index 026b2a06df3..ead898554d1 100644 --- a/spec/frontend/content_editor/extensions/link_spec.js +++ b/spec/frontend/content_editor/extensions/link_spec.js @@ -1,61 +1,46 @@ -import { - markdownLinkSyntaxInputRuleRegExp, - urlSyntaxRegExp, - extractHrefFromMarkdownLink, -} from '~/content_editor/extensions/link'; +import Link from '~/content_editor/extensions/link'; +import { createTestEditor, createDocBuilder, triggerMarkInputRule } from '../test_utils'; describe('content_editor/extensions/link', () => { - describe.each` - input | matches - ${'[gitlab](https://gitlab.com)'} | ${true} - ${'[documentation](readme.md)'} | ${true} - ${'[link 123](readme.md)'} | ${true} - ${'[link 123](read me.md)'} | ${true} - ${'text'} | ${false} - ${'documentation](readme.md'} | ${false} - ${'https://www.google.com'} | ${false} - `('markdownLinkSyntaxInputRuleRegExp', ({ input, matches }) => { - it(`${matches ? 'matches' : 'does not match'} ${input}`, () => { - const match = new RegExp(markdownLinkSyntaxInputRuleRegExp).exec(input); - - expect(Boolean(match?.groups.href)).toBe(matches); - }); + let tiptapEditor; + let doc; + let p; + let link; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Link] }); + ({ + builders: { doc, p, link }, + } = createDocBuilder({ + tiptapEditor, + names: { + link: { markType: Link.name }, + }, + })); }); - describe.each` - input | matches - ${'http://example.com '} | ${true} - ${'https://example.com '} | ${true} - ${'www.example.com '} | ${true} - ${'example.com/ab.html '} | ${false} - ${'text'} | ${false} - ${' http://example.com '} | ${true} - ${'https://www.google.com '} | ${true} - `('urlSyntaxRegExp', ({ input, matches }) => { - it(`${matches ? 'matches' : 'does not match'} ${input}`, () => { - const match = new RegExp(urlSyntaxRegExp).exec(input); - - expect(Boolean(match?.groups.href)).toBe(matches); - }); + afterEach(() => { + tiptapEditor.destroy(); }); - describe('extractHrefFromMarkdownLink', () => { - const input = '[gitlab](https://gitlab.com)'; - const href = 'https://gitlab.com'; - let match; - let result; - - beforeEach(() => { - match = new RegExp(markdownLinkSyntaxInputRuleRegExp).exec(input); - result = extractHrefFromMarkdownLink(match); - }); - - it('extracts the url from a markdown link captured by markdownLinkSyntaxInputRuleRegExp', () => { - expect(result).toEqual({ href }); - }); - - it('makes sure that url text is the last capture group', () => { - expect(match[match.length - 1]).toEqual('gitlab'); - }); + it.each` + input | insertedNode + ${'[gitlab](https://gitlab.com)'} | ${() => p(link({ href: 'https://gitlab.com' }, 'gitlab'))} + ${'[documentation](readme.md)'} | ${() => p(link({ href: 'readme.md' }, 'documentation'))} + ${'[link 123](readme.md)'} | ${() => p(link({ href: 'readme.md' }, 'link 123'))} + ${'[link 123](read me.md)'} | ${() => p(link({ href: 'read me.md' }, 'link 123'))} + ${'text'} | ${() => p('text')} + ${'documentation](readme.md'} | ${() => p('documentation](readme.md')} + ${'http://example.com '} | ${() => p(link({ href: 'http://example.com' }, 'http://example.com'))} + ${'https://example.com '} | ${() => p(link({ href: 'https://example.com' }, 'https://example.com'))} + ${'www.example.com '} | ${() => p(link({ href: 'www.example.com' }, 'www.example.com'))} + ${'example.com/ab.html '} | ${() => p('example.com/ab.html')} + ${'https://www.google.com '} | ${() => p(link({ href: 'https://www.google.com' }, 'https://www.google.com'))} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const expectedDoc = doc(insertedNode()); + + triggerMarkInputRule({ tiptapEditor, inputRuleText: input }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); }); diff --git a/spec/frontend/content_editor/extensions/math_inline_spec.js b/spec/frontend/content_editor/extensions/math_inline_spec.js index 82eb85477de..abf10317b5a 100644 --- a/spec/frontend/content_editor/extensions/math_inline_spec.js +++ b/spec/frontend/content_editor/extensions/math_inline_spec.js @@ -1,5 +1,5 @@ import MathInline from '~/content_editor/extensions/math_inline'; -import { createTestEditor, createDocBuilder } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerMarkInputRule } from '../test_utils'; describe('content_editor/extensions/math_inline', () => { let tiptapEditor; @@ -26,16 +26,9 @@ describe('content_editor/extensions/math_inline', () => { ${'$`a^2`'} | ${() => p('$`a^2`')} ${'`a^2`$'} | ${() => p('`a^2`$')} `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { - const { view } = tiptapEditor; const expectedDoc = doc(insertedNode()); - tiptapEditor.chain().setContent(input).setTextSelection(0).run(); - - const { state } = tiptapEditor; - const { selection } = state; - - // Triggers the event handler that input rules listen to - view.someProp('handleTextInput', (f) => f(view, selection.from, input.length + 1, input)); + triggerMarkInputRule({ tiptapEditor, inputRuleText: input }); expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); diff --git a/spec/frontend/content_editor/extensions/table_of_contents_spec.js b/spec/frontend/content_editor/extensions/table_of_contents_spec.js index 83818899c17..0ddd88b39fe 100644 --- a/spec/frontend/content_editor/extensions/table_of_contents_spec.js +++ b/spec/frontend/content_editor/extensions/table_of_contents_spec.js @@ -1,13 +1,17 @@ import TableOfContents from '~/content_editor/extensions/table_of_contents'; -import { createTestEditor, createDocBuilder } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; -describe('content_editor/extensions/emoji', () => { +describe('content_editor/extensions/table_of_contents', () => { let tiptapEditor; - let builders; + let doc; + let tableOfContents; + let p; beforeEach(() => { tiptapEditor = createTestEditor({ extensions: [TableOfContents] }); - ({ builders } = createDocBuilder({ + ({ + builders: { doc, p, tableOfContents }, + } = createDocBuilder({ tiptapEditor, names: { tableOfContents: { nodeType: TableOfContents.name } }, })); @@ -15,20 +19,16 @@ describe('content_editor/extensions/emoji', () => { it.each` input | insertedNode - ${'[[_TOC_]]'} | ${'tableOfContents'} - ${'[TOC]'} | ${'tableOfContents'} - ${'[toc]'} | ${'p'} - ${'TOC'} | ${'p'} - ${'[_TOC_]'} | ${'p'} - ${'[[TOC]]'} | ${'p'} + ${'[[_TOC_]]'} | ${() => tableOfContents()} + ${'[TOC]'} | ${() => tableOfContents()} + ${'[toc]'} | ${() => p()} + ${'TOC'} | ${() => p()} + ${'[_TOC_]'} | ${() => p()} + ${'[[TOC]]'} | ${() => p()} `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { - const { doc } = builders; - const { view } = tiptapEditor; - const { selection } = view.state; - const expectedDoc = doc(builders[insertedNode]()); + const expectedDoc = doc(insertedNode()); - // Triggers the event handler that input rules listen to - view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); + triggerNodeInputRule({ tiptapEditor, inputRuleText: input }); expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); diff --git a/spec/frontend/content_editor/extensions/table_spec.js b/spec/frontend/content_editor/extensions/table_spec.js new file mode 100644 index 00000000000..121fe9192db --- /dev/null +++ b/spec/frontend/content_editor/extensions/table_spec.js @@ -0,0 +1,102 @@ +import Bold from '~/content_editor/extensions/bold'; +import BulletList from '~/content_editor/extensions/bullet_list'; +import ListItem from '~/content_editor/extensions/list_item'; +import Table from '~/content_editor/extensions/table'; +import TableCell from '~/content_editor/extensions/table_cell'; +import TableRow from '~/content_editor/extensions/table_row'; +import TableHeader from '~/content_editor/extensions/table_header'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/table', () => { + let tiptapEditor; + let doc; + let p; + let table; + let tableHeader; + let tableCell; + let tableRow; + let initialDoc; + let mockAlert; + + beforeEach(() => { + tiptapEditor = createTestEditor({ + extensions: [Table, TableCell, TableRow, TableHeader, BulletList, Bold, ListItem], + }); + + ({ + builders: { doc, p, table, tableCell, tableHeader, tableRow }, + } = createDocBuilder({ + tiptapEditor, + names: { + bold: { markType: Bold.name }, + table: { nodeType: Table.name }, + tableHeader: { nodeType: TableHeader.name }, + tableCell: { nodeType: TableCell.name }, + tableRow: { nodeType: TableRow.name }, + bulletList: { nodeType: BulletList.name }, + listItem: { nodeType: ListItem.name }, + }, + })); + + initialDoc = doc( + table( + { isMarkdown: true }, + tableRow(tableHeader(p('This is')), tableHeader(p('a table'))), + tableRow(tableCell(p('this is')), tableCell(p('the first row'))), + ), + ); + + mockAlert = jest.fn(); + }); + + it('triggers a warning (just once) if the table is markdown, but the changes in the document will render an HTML table instead', () => { + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.on('alert', mockAlert); + + tiptapEditor.commands.setTextSelection({ from: 20, to: 22 }); + tiptapEditor.commands.toggleBulletList(); + + jest.advanceTimersByTime(1001); + expect(mockAlert).toHaveBeenCalled(); + + mockAlert.mockReset(); + + tiptapEditor.commands.setTextSelection({ from: 4, to: 6 }); + tiptapEditor.commands.toggleBulletList(); + + jest.advanceTimersByTime(1001); + expect(mockAlert).not.toHaveBeenCalled(); + }); + + it('does not trigger a warning if the table is markdown, and the changes in the document can generate a markdown table', () => { + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.on('alert', mockAlert); + + tiptapEditor.commands.setTextSelection({ from: 20, to: 22 }); + tiptapEditor.commands.toggleBold(); + + jest.advanceTimersByTime(1001); + expect(mockAlert).not.toHaveBeenCalled(); + }); + + it('does not trigger any warnings if the table is not markdown', () => { + initialDoc = doc( + table( + tableRow(tableHeader(p('This is')), tableHeader(p('a table'))), + tableRow(tableCell(p('this is')), tableCell(p('the first row'))), + ), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.on('alert', mockAlert); + + tiptapEditor.commands.setTextSelection({ from: 20, to: 22 }); + tiptapEditor.commands.toggleBulletList(); + + jest.advanceTimersByTime(1001); + expect(mockAlert).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/content_editor/extensions/word_break_spec.js b/spec/frontend/content_editor/extensions/word_break_spec.js new file mode 100644 index 00000000000..23167269d7d --- /dev/null +++ b/spec/frontend/content_editor/extensions/word_break_spec.js @@ -0,0 +1,35 @@ +import WordBreak from '~/content_editor/extensions/word_break'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; + +describe('content_editor/extensions/word_break', () => { + let tiptapEditor; + let doc; + let p; + let wordBreak; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [WordBreak] }); + + ({ + builders: { doc, p, wordBreak }, + } = createDocBuilder({ + tiptapEditor, + names: { + wordBreak: { nodeType: WordBreak.name }, + }, + })); + }); + + it.each` + input | insertedNode + ${''} | ${() => p(wordBreak())} + ${' p()} + ${'wbr>'} | ${() => p()} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const expectedDoc = doc(insertedNode()); + + triggerNodeInputRule({ tiptapEditor, inputRuleText: input }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 33056ab9e4a..cfd93c2df10 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -34,10 +34,6 @@ import { createTestEditor, createDocBuilder } from '../test_utils'; jest.mock('~/emoji'); -jest.mock('~/content_editor/services/feature_flags', () => ({ - isBlockTablesFeatureEnabled: jest.fn().mockReturnValue(true), -})); - const tiptapEditor = createTestEditor({ extensions: [ Blockquote, diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js index afe09a75f16..459780cc7cf 100644 --- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -10,7 +10,7 @@ import Heading from '~/content_editor/extensions/heading'; import ListItem from '~/content_editor/extensions/list_item'; import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_rules_and_shortcuts'; import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; -import { createTestEditor } from '../test_utils'; +import { createTestEditor, triggerNodeInputRule } from '../test_utils'; describe('content_editor/services/track_input_rules_and_shortcuts', () => { let trackingSpy; @@ -70,14 +70,7 @@ describe('content_editor/services/track_input_rules_and_shortcuts', () => { describe('when creating a heading using an input rule', () => { it('sends a tracking event indicating that a heading was created using an input rule', async () => { const nodeName = Heading.name; - const { view } = editor; - const { selection } = view.state; - - // Triggers the event handler that input rules listen to - view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, '## ')); - - editor.chain().insertContent(HEADING_TEXT).run(); - + triggerNodeInputRule({ tiptapEditor: editor, inputRuleText: '## ' }); expect(trackingSpy).toHaveBeenCalledWith(undefined, INPUT_RULE_TRACKING_ACTION, { label: CONTENT_EDITOR_TRACKING_LABEL, property: `${nodeName}`, diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index cf5aa3f2938..b236c630e13 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -119,3 +119,26 @@ export const createTestContentEditorExtension = ({ commands = [] } = {}) => { }, }; }; + +export const triggerNodeInputRule = ({ tiptapEditor, inputRuleText }) => { + const { view } = tiptapEditor; + const { state } = tiptapEditor; + const { selection } = state; + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, inputRuleText)); +}; + +export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => { + const { view } = tiptapEditor; + + tiptapEditor.chain().setContent(inputRuleText).setTextSelection(0).run(); + + const { state } = tiptapEditor; + const { selection } = state; + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => + f(view, selection.from, inputRuleText.length + 1, inputRuleText), + ); +}; diff --git a/spec/frontend/create_merge_request_dropdown_spec.js b/spec/frontend/create_merge_request_dropdown_spec.js index 8878891701f..9f07eea433a 100644 --- a/spec/frontend/create_merge_request_dropdown_spec.js +++ b/spec/frontend/create_merge_request_dropdown_spec.js @@ -46,7 +46,10 @@ describe('CreateMergeRequestDropdown', () => { dropdown .getRef('contains#hash') .then(() => { - expect(axios.get).toHaveBeenCalledWith(endpoint); + expect(axios.get).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ cancelToken: expect.anything() }), + ); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js new file mode 100644 index 00000000000..79b85969eb4 --- /dev/null +++ b/spec/frontend/crm/contacts_root_spec.js @@ -0,0 +1,60 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import ContactsRoot from '~/crm/components/contacts_root.vue'; +import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; +import { getGroupContactsQueryResponse } from './mock_data'; + +jest.mock('~/flash'); + +describe('Customer relations contacts root app', () => { + Vue.use(VueApollo); + let wrapper; + let fakeApollo; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); + const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse); + + const mountComponent = ({ + queryHandler = successQueryHandler, + mountFunction = shallowMountExtended, + } = {}) => { + fakeApollo = createMockApollo([[getGroupContactsQuery, queryHandler]]); + wrapper = mountFunction(ContactsRoot, { + provide: { groupFullPath: 'flightjs' }, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('should render loading spinner', () => { + mountComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('should render error message on reject', async () => { + mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + it('renders correct results', async () => { + mountComponent({ mountFunction: mountExtended }); + await waitForPromises(); + + expect(findRowByName(/Marty/i)).toHaveLength(1); + expect(findRowByName(/George/i)).toHaveLength(1); + expect(findRowByName(/jd@gitlab.com/i)).toHaveLength(1); + }); +}); diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js new file mode 100644 index 00000000000..4197621aaa6 --- /dev/null +++ b/spec/frontend/crm/mock_data.js @@ -0,0 +1,81 @@ +export const getGroupContactsQueryResponse = { + data: { + group: { + __typename: 'Group', + id: 'gid://gitlab/Group/26', + contacts: { + nodes: [ + { + __typename: 'CustomerRelationsContact', + id: 'gid://gitlab/CustomerRelations::Contact/12', + firstName: 'Marty', + lastName: 'McFly', + email: 'example@gitlab.com', + phone: null, + description: null, + organization: { + __typename: 'CustomerRelationsOrganization', + id: 'gid://gitlab/CustomerRelations::Organization/2', + name: 'Tech Giant Inc', + }, + }, + { + __typename: 'CustomerRelationsContact', + id: 'gid://gitlab/CustomerRelations::Contact/16', + firstName: 'Boy', + lastName: 'George', + email: null, + phone: null, + description: null, + organization: null, + }, + { + __typename: 'CustomerRelationsContact', + id: 'gid://gitlab/CustomerRelations::Contact/13', + firstName: 'Jane', + lastName: 'Doe', + email: 'jd@gitlab.com', + phone: '+44 44 4444 4444', + description: 'Vice President', + organization: null, + }, + ], + __typename: 'CustomerRelationsContactConnection', + }, + }, + }, +}; + +export const getGroupOrganizationsQueryResponse = { + data: { + group: { + __typename: 'Group', + id: 'gid://gitlab/Group/26', + organizations: { + nodes: [ + { + __typename: 'CustomerRelationsOrganization', + id: 'gid://gitlab/CustomerRelations::Organization/1', + name: 'Test Inc', + defaultRate: 100, + description: null, + }, + { + __typename: 'CustomerRelationsOrganization', + id: 'gid://gitlab/CustomerRelations::Organization/2', + name: 'ABC Company', + defaultRate: 110, + description: 'VIP', + }, + { + __typename: 'CustomerRelationsOrganization', + id: 'gid://gitlab/CustomerRelations::Organization/3', + name: 'GitLab', + defaultRate: 120, + description: null, + }, + ], + }, + }, + }, +}; diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js new file mode 100644 index 00000000000..a69a099e03d --- /dev/null +++ b/spec/frontend/crm/organizations_root_spec.js @@ -0,0 +1,60 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import OrganizationsRoot from '~/crm/components/organizations_root.vue'; +import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; +import { getGroupOrganizationsQueryResponse } from './mock_data'; + +jest.mock('~/flash'); + +describe('Customer relations organizations root app', () => { + Vue.use(VueApollo); + let wrapper; + let fakeApollo; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); + const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse); + + const mountComponent = ({ + queryHandler = successQueryHandler, + mountFunction = shallowMountExtended, + } = {}) => { + fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]); + wrapper = mountFunction(OrganizationsRoot, { + provide: { groupFullPath: 'flightjs' }, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('should render loading spinner', () => { + mountComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('should render error message on reject', async () => { + mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + it('renders correct results', async () => { + mountComponent({ mountFunction: mountExtended }); + await waitForPromises(); + + expect(findRowByName(/Test Inc/i)).toHaveLength(1); + expect(findRowByName(/VIP/i)).toHaveLength(1); + expect(findRowByName(/120/i)).toHaveLength(1); + }); +}); diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js index c41adf523f8..2001f5c1441 100644 --- a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js +++ b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js @@ -4,8 +4,6 @@ import { TEST_HOST } from 'helpers/test_constants'; import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import axios from '~/lib/utils/axios_utils'; -const { CancelToken } = axios; - describe('custom metrics form fields component', () => { let wrapper; let mockAxios; @@ -116,14 +114,14 @@ describe('custom metrics form fields component', () => { it('receives and validates a persisted value', () => { const query = 'persistedQuery'; - const axiosPost = jest.spyOn(axios, 'post'); - const source = CancelToken.source(); + jest.spyOn(axios, 'post'); + mountComponent({ metricPersisted: true, ...makeFormData({ query }) }); - expect(axiosPost).toHaveBeenCalledWith( + expect(axios.post).toHaveBeenCalledWith( validateQueryPath, { query }, - { cancelToken: source.token }, + expect.objectContaining({ cancelToken: expect.anything() }), ); expect(getNamedInput(queryInputName).value).toBe(query); jest.runAllTimers(); diff --git a/spec/frontend/cycle_analytics/metric_popover_spec.js b/spec/frontend/cycle_analytics/metric_popover_spec.js new file mode 100644 index 00000000000..5a622fcacd5 --- /dev/null +++ b/spec/frontend/cycle_analytics/metric_popover_spec.js @@ -0,0 +1,102 @@ +import { GlLink, GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import MetricPopover from '~/cycle_analytics/components/metric_popover.vue'; + +const MOCK_METRIC = { + key: 'deployment-frequency', + label: 'Deployment Frequency', + value: '10.0', + unit: 'per day', + description: 'Average number of deployments to production per day.', + links: [], +}; + +describe('MetricPopover', () => { + let wrapper; + + const createComponent = (props = {}) => { + return shallowMountExtended(MetricPopover, { + propsData: { + target: 'deployment-frequency', + ...props, + }, + stubs: { + 'gl-popover': { template: '
' }, + }, + }); + }; + + const findMetricLabel = () => wrapper.findByTestId('metric-label'); + const findAllMetricLinks = () => wrapper.findAll('[data-testid="metric-link"]'); + const findMetricDescription = () => wrapper.findByTestId('metric-description'); + const findMetricDocsLink = () => wrapper.findByTestId('metric-docs-link'); + const findMetricDocsLinkIcon = () => findMetricDocsLink().find(GlIcon); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the metric label', () => { + wrapper = createComponent({ metric: MOCK_METRIC }); + expect(findMetricLabel().text()).toBe(MOCK_METRIC.label); + }); + + it('renders the metric description', () => { + wrapper = createComponent({ metric: MOCK_METRIC }); + expect(findMetricDescription().text()).toBe(MOCK_METRIC.description); + }); + + describe('with links', () => { + const links = [ + { + name: 'Deployment frequency', + url: '/groups/gitlab-org/-/analytics/ci_cd?tab=deployment-frequency', + label: 'Dashboard', + }, + { + name: 'Another link', + url: '/groups/gitlab-org/-/analytics/another-link', + label: 'Another link', + }, + ]; + const docsLink = { + name: 'Deployment frequency', + url: '/help/user/analytics/index#definitions', + label: 'Go to docs', + docs_link: true, + }; + const linksWithDocs = [...links, docsLink]; + + describe.each` + hasDocsLink | allLinks | displayedMetricLinks + ${true} | ${linksWithDocs} | ${links} + ${false} | ${links} | ${links} + `( + 'when one link has docs_link=$hasDocsLink', + ({ hasDocsLink, allLinks, displayedMetricLinks }) => { + beforeEach(() => { + wrapper = createComponent({ metric: { ...MOCK_METRIC, links: allLinks } }); + }); + + displayedMetricLinks.forEach((link, idx) => { + it(`renders a link for "${link.name}"`, () => { + const allLinkContainers = findAllMetricLinks(); + + expect(allLinkContainers.at(idx).text()).toContain(link.name); + expect(allLinkContainers.at(idx).find(GlLink).attributes('href')).toBe(link.url); + }); + }); + + it(`${hasDocsLink ? 'renders' : "doesn't render"} a docs link`, () => { + expect(findMetricDocsLink().exists()).toBe(hasDocsLink); + + if (hasDocsLink) { + expect(findMetricDocsLink().attributes('href')).toBe(docsLink.url); + expect(findMetricDocsLink().text()).toBe(docsLink.label); + expect(findMetricDocsLinkIcon().attributes('name')).toBe('external-link'); + } + }); + }, + ); + }); +}); diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index 1882457960a..c482bd4e910 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -1,10 +1,14 @@ -/* eslint-disable import/no-deprecated */ +import valueStreamAnalyticsStages from 'test_fixtures/projects/analytics/value_stream_analytics/stages.json'; +import issueStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/issue.json'; +import planStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/plan.json'; +import reviewStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/review.json'; +import codeStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/code.json'; +import testStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/test.json'; +import stagingStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/staging.json'; -import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import { DEFAULT_VALUE_STREAM, - DEFAULT_DAYS_IN_PAST, PAGINATION_TYPE, PAGINATION_SORT_DIRECTION_DESC, PAGINATION_SORT_FIELD_END_EVENT, @@ -12,6 +16,7 @@ import { import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { getDateInPast } from '~/lib/utils/datetime_utility'; +const DEFAULT_DAYS_IN_PAST = 30; export const createdBefore = new Date(2019, 0, 14); export const createdAfter = getDateInPast(createdBefore, DEFAULT_DAYS_IN_PAST); @@ -20,28 +25,16 @@ export const deepCamelCase = (obj) => convertObjectPropsToCamelCase(obj, { deep: export const getStageByTitle = (stages, title) => stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {}; -const fixtureEndpoints = { - customizableCycleAnalyticsStagesAndEvents: - 'projects/analytics/value_stream_analytics/stages.json', - stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}.json`, - metricsData: 'projects/analytics/value_stream_analytics/summary.json', -}; - -export const metricsData = getJSONFixture(fixtureEndpoints.metricsData); - -export const customizableStagesAndEvents = getJSONFixture( - fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents, -); - export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging']; -const stageFixtures = defaultStages.reduce((acc, stage) => { - const events = getJSONFixture(fixtureEndpoints.stageEvents(stage)); - return { - ...acc, - [stage]: events, - }; -}, {}); +const stageFixtures = { + issue: issueStageFixtures, + plan: planStageFixtures, + review: reviewStageFixtures, + code: codeStageFixtures, + test: testStageFixtures, + staging: stagingStageFixtures, +}; export const summary = [ { value: '20', title: 'New Issues' }, @@ -260,7 +253,7 @@ export const selectedProjects = [ }, ]; -export const rawValueStreamStages = customizableStagesAndEvents.stages; +export const rawValueStreamStages = valueStreamAnalyticsStages.stages; export const valueStreamStages = rawValueStreamStages.map((s) => convertObjectPropsToCamelCase(s, { deep: true }), diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 993e6b6b73a..e775e941b4c 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -57,22 +57,12 @@ describe('Project Value Stream Analytics actions', () => { const mutationTypes = (arr) => arr.map(({ type }) => type); - const mockFetchStageDataActions = [ - { type: 'setLoading', payload: true }, - { type: 'fetchCycleAnalyticsData' }, - { type: 'fetchStageData' }, - { type: 'fetchStageMedians' }, - { type: 'fetchStageCountValues' }, - { type: 'setLoading', payload: false }, - ]; - describe.each` - action | payload | expectedActions | expectedMutations - ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]} - ${'setDateRange'} | ${{ createdAfter, createdBefore }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]} - ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]} - ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} - ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} + action | payload | expectedActions | expectedMutations + ${'setDateRange'} | ${{ createdAfter, createdBefore }} | ${[{ type: 'refetchStageData' }]} | ${[mockSetDateActionCommit]} + ${'setFilters'} | ${[]} | ${[{ type: 'refetchStageData' }]} | ${[]} + ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'refetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} + ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} `('$action', ({ action, payload, expectedActions, expectedMutations }) => { const types = mutationTypes(expectedMutations); it(`will dispatch ${expectedActions} and commit ${types}`, () => @@ -86,9 +76,18 @@ describe('Project Value Stream Analytics actions', () => { }); describe('initializeVsa', () => { - let mockDispatch; - let mockCommit; - const payload = { endpoints: mockEndpoints }; + const selectedAuthor = 'Author'; + const selectedMilestone = 'Milestone 1'; + const selectedAssigneeList = ['Assignee 1', 'Assignee 2']; + const selectedLabelList = ['Label 1', 'Label 2']; + const payload = { + endpoints: mockEndpoints, + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + selectedStage, + }; const mockFilterEndpoints = { groupEndpoint: 'foo', labelsEndpoint: mockLabelsPath, @@ -96,27 +95,63 @@ describe('Project Value Stream Analytics actions', () => { projectEndpoint: '/namespace/-/analytics/value_stream_analytics/value_streams', }; + it('will dispatch fetchValueStreams actions and commit SET_LOADING and INITIALIZE_VSA', () => { + return testAction({ + action: actions.initializeVsa, + state: {}, + payload, + expectedMutations: [ + { type: 'INITIALIZE_VSA', payload }, + { type: 'SET_LOADING', payload: true }, + { type: 'SET_LOADING', payload: false }, + ], + expectedActions: [ + { type: 'filters/setEndpoints', payload: mockFilterEndpoints }, + { + type: 'filters/initialize', + payload: { selectedAuthor, selectedMilestone, selectedAssigneeList, selectedLabelList }, + }, + { type: 'fetchValueStreams' }, + { type: 'setInitialStage', payload: selectedStage }, + ], + }); + }); + }); + + describe('setInitialStage', () => { beforeEach(() => { - mockDispatch = jest.fn(() => Promise.resolve()); - mockCommit = jest.fn(); + state = { ...state, stages: allowedStages }; }); - it('will dispatch the setLoading and fetchValueStreams actions and commit INITIALIZE_VSA', async () => { - await actions.initializeVsa( - { - ...state, - dispatch: mockDispatch, - commit: mockCommit, - }, - payload, - ); - expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints }); - - expect(mockDispatch).toHaveBeenCalledTimes(4); - expect(mockDispatch).toHaveBeenCalledWith('filters/setEndpoints', mockFilterEndpoints); - expect(mockDispatch).toHaveBeenCalledWith('setLoading', true); - expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams'); - expect(mockDispatch).toHaveBeenCalledWith('setLoading', false); + describe('with a selected stage', () => { + it('will commit `SET_SELECTED_STAGE` and fetchValueStreamStageData actions', () => { + const fakeStage = { ...selectedStage, id: 'fake', name: 'fake-stae' }; + return testAction({ + action: actions.setInitialStage, + state, + payload: fakeStage, + expectedMutations: [ + { + type: 'SET_SELECTED_STAGE', + payload: fakeStage, + }, + ], + expectedActions: [{ type: 'fetchValueStreamStageData' }], + }); + }); + }); + + describe('without a selected stage', () => { + it('will select the first stage from the value stream', () => { + const [firstStage] = allowedStages; + testAction({ + action: actions.setInitialStage, + state, + payload: null, + expectedMutations: [{ type: 'SET_SELECTED_STAGE', payload: firstStage }], + expectedActions: [{ type: 'fetchValueStreamStageData' }], + }); + }); }); }); @@ -270,12 +305,7 @@ describe('Project Value Stream Analytics actions', () => { state, payload: {}, expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], - expectedActions: [ - { type: 'receiveValueStreamsSuccess' }, - { type: 'setSelectedStage' }, - { type: 'fetchStageMedians' }, - { type: 'fetchStageCountValues' }, - ], + expectedActions: [{ type: 'receiveValueStreamsSuccess' }], })); describe('with a failing request', () => { @@ -483,4 +513,34 @@ describe('Project Value Stream Analytics actions', () => { })); }); }); + + describe('refetchStageData', () => { + it('will commit SET_LOADING and dispatch fetchValueStreamStageData actions', () => + testAction({ + action: actions.refetchStageData, + state, + payload: {}, + expectedMutations: [ + { type: 'SET_LOADING', payload: true }, + { type: 'SET_LOADING', payload: false }, + ], + expectedActions: [{ type: 'fetchValueStreamStageData' }], + })); + }); + + describe('fetchValueStreamStageData', () => { + it('will dispatch the fetchCycleAnalyticsData, fetchStageData, fetchStageMedians and fetchStageCountValues actions', () => + testAction({ + action: actions.fetchValueStreamStageData, + state, + payload: {}, + expectedMutations: [], + expectedActions: [ + { type: 'fetchCycleAnalyticsData' }, + { type: 'fetchStageData' }, + { type: 'fetchStageMedians' }, + { type: 'fetchStageCountValues' }, + ], + })); + }); }); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 4860225c995..2670a390e9c 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -101,6 +101,7 @@ describe('Project Value Stream Analytics mutations', () => { ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} ${types.SET_PAGINATION} | ${pagination} | ${'pagination'} | ${{ ...pagination, sort: PAGINATION_SORT_FIELD_END_EVENT, direction: PAGINATION_SORT_DIRECTION_DESC }} ${types.SET_PAGINATION} | ${{ ...pagination, sort: 'duration', direction: 'asc' }} | ${'pagination'} | ${{ ...pagination, sort: 'duration', direction: 'asc' }} + ${types.SET_SELECTED_STAGE} | ${selectedStage} | ${'selectedStage'} | ${selectedStage} ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index 74d64cd8d71..a6d6d022781 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -1,11 +1,11 @@ -import { useFakeDate } from 'helpers/fake_date'; +import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json'; import { transformStagesForPathNavigation, medianTimeToParsedSeconds, formatMedianValues, filterStagesByHiddenStatus, - calculateFormattedDayInPast, prepareTimeMetricsData, + buildCycleAnalyticsInitialData, } from '~/cycle_analytics/utils'; import { slugify } from '~/lib/utils/text_utility'; import { @@ -14,7 +14,6 @@ import { stageMedians, pathNavIssueMetric, rawStageMedians, - metricsData, } from './mock_data'; describe('Value stream analytics utils', () => { @@ -90,14 +89,6 @@ describe('Value stream analytics utils', () => { }); }); - describe('calculateFormattedDayInPast', () => { - useFakeDate(1815, 11, 10); - - it('will return 2 dates, now and past', () => { - expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' }); - }); - }); - describe('prepareTimeMetricsData', () => { let prepared; const [first, second] = metricsData; @@ -125,4 +116,87 @@ describe('Value stream analytics utils', () => { ]); }); }); + + describe('buildCycleAnalyticsInitialData', () => { + let res = null; + const projectId = '5'; + const createdAfter = '2021-09-01'; + const createdBefore = '2021-11-06'; + const groupId = '146'; + const groupPath = 'fake-group'; + const fullPath = 'fake-group/fake-project'; + const labelsPath = '/fake-group/fake-project/-/labels.json'; + const milestonesPath = '/fake-group/fake-project/-/milestones.json'; + const requestPath = '/fake-group/fake-project/-/value_stream_analytics'; + + const rawData = { + projectId, + createdBefore, + createdAfter, + fullPath, + requestPath, + labelsPath, + milestonesPath, + groupId, + groupPath, + }; + + describe('with minimal data', () => { + beforeEach(() => { + res = buildCycleAnalyticsInitialData(rawData); + }); + + it('sets the projectId', () => { + expect(res.projectId).toBe(parseInt(projectId, 10)); + }); + + it('sets the date range', () => { + expect(res.createdBefore).toEqual(new Date(createdBefore)); + expect(res.createdAfter).toEqual(new Date(createdAfter)); + }); + + it('sets the endpoints', () => { + const { endpoints } = res; + expect(endpoints.fullPath).toBe(fullPath); + expect(endpoints.requestPath).toBe(requestPath); + expect(endpoints.labelsPath).toBe(labelsPath); + expect(endpoints.milestonesPath).toBe(milestonesPath); + expect(endpoints.groupId).toBe(parseInt(groupId, 10)); + expect(endpoints.groupPath).toBe(groupPath); + }); + + it('returns null when there is no stage', () => { + expect(res.selectedStage).toBeNull(); + }); + + it('returns false for missing features', () => { + expect(res.features.cycleAnalyticsForGroups).toBe(false); + }); + }); + + describe('with a stage set', () => { + const jsonStage = '{"id":"fakeStage","title":"fakeStage"}'; + + it('parses the selectedStage data', () => { + res = buildCycleAnalyticsInitialData({ ...rawData, stage: jsonStage }); + + const { selectedStage: stage } = res; + + expect(stage.id).toBe('fakeStage'); + expect(stage.title).toBe('fakeStage'); + }); + }); + + describe('with features set', () => { + const fakeFeatures = { cycleAnalyticsForGroups: true }; + + it('sets the feature flags', () => { + res = buildCycleAnalyticsInitialData({ + ...rawData, + gon: { licensed_features: fakeFeatures }, + }); + expect(res.features).toEqual(fakeFeatures); + }); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js index ffdb49a828c..c97e4845bc2 100644 --- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -1,13 +1,16 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { shallowMount } from '@vue/test-utils'; +import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json'; import waitForPromises from 'helpers/wait_for_promises'; import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api'; import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; import createFlash from '~/flash'; -import { group, metricsData } from './mock_data'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { group } from './mock_data'; jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility'); describe('ValueStreamMetrics', () => { let wrapper; @@ -43,7 +46,6 @@ describe('ValueStreamMetrics', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('with successful requests', () => { @@ -55,7 +57,23 @@ describe('ValueStreamMetrics', () => { it('will display a loader with pending requests', async () => { await wrapper.vm.$nextTick(); - expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + expect(wrapper.findComponent(GlSkeletonLoading).exists()).toBe(true); + }); + + it('renders hidden GlSingleStat components for each metric', async () => { + await waitForPromises(); + + wrapper.setData({ isLoading: true }); + + await wrapper.vm.$nextTick(); + + const components = findMetrics(); + + expect(components).toHaveLength(metricsData.length); + + metricsData.forEach((metric, index) => { + expect(components.at(index).isVisible()).toBe(false); + }); }); describe('with data loaded', () => { @@ -67,19 +85,31 @@ describe('ValueStreamMetrics', () => { expectToHaveRequest({ params: {} }); }); - it.each` - index | value | title | unit - ${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit} - ${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit} - ${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit} - ${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit} - `( - 'renders a single stat component for the $title with value and unit', - ({ index, value, title, unit }) => { + describe.each` + index | value | title | unit | clickable + ${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit} | ${false} + ${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit} | ${false} + ${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit} | ${false} + ${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit} | ${true} + `('metric tiles', ({ index, value, title, unit, clickable }) => { + it(`renders a single stat component for "${title}" with value and unit`, () => { const metric = findMetrics().at(index); expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' }); - }, - ); + expect(metric.isVisible()).toBe(true); + }); + + it(`${ + clickable ? 'redirects' : "doesn't redirect" + } when the user clicks the "${title}" metric`, () => { + const metric = findMetrics().at(index); + metric.vm.$emit('click'); + if (clickable) { + expect(redirectTo).toHaveBeenCalledWith(metricsData[index].links[0].url); + } else { + expect(redirectTo).not.toHaveBeenCalled(); + } + }); + }); it('will not display a loading icon', () => { expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); diff --git a/spec/frontend/delete_label_modal_spec.js b/spec/frontend/delete_label_modal_spec.js index df70d3a8393..0b3e6fe652a 100644 --- a/spec/frontend/delete_label_modal_spec.js +++ b/spec/frontend/delete_label_modal_spec.js @@ -40,7 +40,7 @@ describe('DeleteLabelModal', () => { it('starts with only js-containers', () => { expect(findJsHooks()).toHaveLength(buttons.length); - expect(findModal()).not.toExist(); + expect(findModal()).toBe(null); }); describe('when first button clicked', () => { @@ -54,7 +54,7 @@ describe('DeleteLabelModal', () => { }); it('renders GlModal', () => { - expect(findModal()).toExist(); + expect(findModal()).not.toBe(null); }); }); diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js index 511b9d6ef55..51c120d8213 100644 --- a/spec/frontend/deploy_keys/components/key_spec.js +++ b/spec/frontend/deploy_keys/components/key_spec.js @@ -50,20 +50,20 @@ describe('Deploy keys key', () => { it('shows pencil button for editing', () => { createComponent({ deployKey }); - expect(wrapper.find('.btn [data-testid="pencil-icon"]')).toExist(); + expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true); }); it('shows disable button when the project is not deletable', () => { createComponent({ deployKey }); - expect(wrapper.find('.btn [data-testid="cancel-icon"]')).toExist(); + expect(wrapper.find('.btn [data-testid="cancel-icon"]').exists()).toBe(true); }); it('shows remove button when the project is deletable', () => { createComponent({ deployKey: { ...deployKey, destroyed_when_orphaned: true, almost_orphaned: true }, }); - expect(wrapper.find('.btn [data-testid="remove-icon"]')).toExist(); + expect(wrapper.find('.btn [data-testid="remove-icon"]').exists()).toBe(true); }); }); @@ -137,7 +137,7 @@ describe('Deploy keys key', () => { it('shows pencil button for editing', () => { createComponent({ deployKey }); - expect(wrapper.find('.btn [data-testid="pencil-icon"]')).toExist(); + expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true); }); it('shows disable button when key is enabled', () => { @@ -145,7 +145,7 @@ describe('Deploy keys key', () => { createComponent({ deployKey }); - expect(wrapper.find('.btn [data-testid="cancel-icon"]')).toExist(); + expect(wrapper.find('.btn [data-testid="cancel-icon"]').exists()).toBe(true); }); }); }); diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js index f3b907e5450..f5f76d5d493 100644 --- a/spec/frontend/deploy_keys/components/keys_panel_spec.js +++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js @@ -37,7 +37,7 @@ describe('Deploy keys panel', () => { mountComponent(); const tableHeader = findTableRowHeader(); - expect(tableHeader).toExist(); + expect(tableHeader.exists()).toBe(true); expect(tableHeader.text()).toContain('Deploy key'); expect(tableHeader.text()).toContain('Project usage'); expect(tableHeader.text()).toContain('Created'); diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 7e4c6e131b4..bec91fe5fc5 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -1,10 +1,9 @@ /* eslint-disable no-param-reassign */ import $ from 'jquery'; +import mockProjects from 'test_fixtures_static/projects.json'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import '~/lib/utils/common_utils'; -// eslint-disable-next-line import/no-deprecated -import { getJSONFixture } from 'helpers/fixtures'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -68,8 +67,7 @@ describe('deprecatedJQueryDropdown', () => { loadFixtures('static/deprecated_jquery_dropdown.html'); test.dropdownContainerElement = $('.dropdown.inline'); test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement); - // eslint-disable-next-line import/no-deprecated - test.projectsData = getJSONFixture('static/projects.json'); + test.projectsData = JSON.parse(JSON.stringify(mockProjects)); }); afterEach(() => { diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js index 58636ece91e..ed105b112be 100644 --- a/spec/frontend/design_management/components/list/item_spec.js +++ b/spec/frontend/design_management/components/list/item_spec.js @@ -87,7 +87,7 @@ describe('Design management list item component', () => { describe('before image is loaded', () => { it('renders loading spinner', () => { - expect(wrapper.find(GlLoadingIcon)).toExist(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index ce79feae2e7..427161a391b 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -669,6 +669,20 @@ describe('Design management index page', () => { expect(variables.files).toEqual(event.clipboardData.files.map((f) => new File([f], ''))); }); + it('display original file name', () => { + event.clipboardData.files = [new File([new Blob()], 'test.png', { type: 'image/png' })]; + document.dispatchEvent(event); + + const [{ mutation, variables }] = mockMutate.mock.calls[0]; + expect(mutation).toBe(uploadDesignMutation); + expect(variables).toStrictEqual({ + files: expect.any(Array), + iid: '1', + projectPath: 'project-path', + }); + expect(variables.files[0].name).toEqual('test.png'); + }); + it('renames a design if it has an image.png filename', () => { event.clipboardData.getData = () => 'image.png'; document.dispatchEvent(event); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 0527c2153f4..d50ac0529d6 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -388,15 +388,24 @@ describe('diffs/components/app', () => { wrapper.vm.jumpToFile(+1); - expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual(['diffs/scrollToFile', '222.js']); + expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([ + 'diffs/scrollToFile', + { path: '222.js' }, + ]); store.state.diffs.currentDiffFileId = '222'; wrapper.vm.jumpToFile(+1); - expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual(['diffs/scrollToFile', '333.js']); + expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([ + 'diffs/scrollToFile', + { path: '333.js' }, + ]); store.state.diffs.currentDiffFileId = '333'; wrapper.vm.jumpToFile(-1); - expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual(['diffs/scrollToFile', '222.js']); + expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([ + 'diffs/scrollToFile', + { path: '222.js' }, + ]); }); it('does not jump to previous file from the first one', async () => { @@ -702,23 +711,4 @@ describe('diffs/components/app', () => { ); }); }); - - describe('fluid layout', () => { - beforeEach(() => { - setFixtures( - '
', - ); - }); - - it('removes limited container classes when on diffs tab', () => { - createComponent({ isFluidLayout: false, shouldShow: true }, () => {}, { - glFeatures: { mrChangesFluidLayout: true }, - }); - - const containerClassList = document.querySelector('.merge-request-container').classList; - - expect(containerClassList).not.toContain('container-limited'); - expect(containerClassList).not.toContain('limit-container-width'); - }); - }); }); diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js index bd6f4cd2545..c847a79435a 100644 --- a/spec/frontend/diffs/components/diff_discussions_spec.js +++ b/spec/frontend/diffs/components/diff_discussions_spec.js @@ -1,6 +1,7 @@ import { GlIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; +import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; import { createStore } from '~/mr_notes/stores'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; @@ -19,6 +20,9 @@ describe('DiffDiscussions', () => { store = createStore(); wrapper = mount(localVue.extend(DiffDiscussions), { store, + provide: { + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, propsData: { discussions: getDiscussionsMockData(), ...props, diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index b16ef8fe6b0..342b4bfcc50 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -7,7 +7,7 @@ import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import { DIFF_FILE_AUTOMATIC_COLLAPSE, DIFF_FILE_MANUAL_COLLAPSE } from '~/diffs/constants'; import { reviewFile } from '~/diffs/store/actions'; -import { SET_MR_FILE_REVIEWS } from '~/diffs/store/mutation_types'; +import { SET_DIFF_FILE_VIEWED, SET_MR_FILE_REVIEWS } from '~/diffs/store/mutation_types'; import { diffViewerModes } from '~/ide/constants'; import { scrollToElement } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -23,6 +23,7 @@ jest.mock('~/lib/utils/common_utils'); const diffFile = Object.freeze( Object.assign(diffDiscussionsMockData.diff_file, { id: '123', + file_hash: 'xyz', file_identifier_hash: 'abc', edit_path: 'link:/to/edit/path', blob: { @@ -58,7 +59,7 @@ describe('DiffFileHeader component', () => { toggleFileDiscussions: jest.fn(), toggleFileDiscussionWrappers: jest.fn(), toggleFullDiff: jest.fn(), - toggleActiveFileByHash: jest.fn(), + setCurrentFileHash: jest.fn(), setFileCollapsedByUser: jest.fn(), reviewFile: jest.fn(), }, @@ -240,18 +241,19 @@ describe('DiffFileHeader component', () => { }); describe('for any file', () => { - const otherModes = Object.keys(diffViewerModes).filter((m) => m !== 'mode_changed'); + const allModes = Object.keys(diffViewerModes).map((m) => [m]); - it('for mode_changed file mode displays mode changes', () => { + it.each(allModes)('for %s file mode displays mode changes', (mode) => { createComponent({ props: { diffFile: { ...diffFile, + mode_changed: true, a_mode: 'old-mode', b_mode: 'new-mode', viewer: { ...diffFile.viewer, - name: diffViewerModes.mode_changed, + name: diffViewerModes[mode], }, }, }, @@ -259,13 +261,14 @@ describe('DiffFileHeader component', () => { expect(findModeChangedLine().text()).toMatch(/old-mode.+new-mode/); }); - it.each(otherModes.map((m) => [m]))( + it.each(allModes.filter((m) => m[0] !== 'mode_changed'))( 'for %s file mode does not display mode changes', (mode) => { createComponent({ props: { diffFile: { ...diffFile, + mode_changed: false, a_mode: 'old-mode', b_mode: 'new-mode', viewer: { @@ -553,7 +556,13 @@ describe('DiffFileHeader component', () => { reviewFile, { file, reviewed: true }, {}, - [{ type: SET_MR_FILE_REVIEWS, payload: { [file.file_identifier_hash]: [file.id] } }], + [ + { type: SET_DIFF_FILE_VIEWED, payload: { id: file.file_hash, seen: true } }, + { + type: SET_MR_FILE_REVIEWS, + payload: { [file.file_identifier_hash]: [file.id, `hash:${file.file_hash}`] }, + }, + ], [], ); }); 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 a192f7e2e9a..0ccf996e220 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -1,10 +1,18 @@ import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; 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 diffFileMockData from '../mock_data/diff_file'; +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => { + return { + confirmAction: jest.fn(), + }; +}); + describe('DiffLineNoteForm', () => { let wrapper; let diffFile; @@ -24,57 +32,68 @@ describe('DiffLineNoteForm', () => { return shallowMount(DiffLineNoteForm, { store, propsData: { - diffFileHash: diffFile.file_hash, - diffLines, - line: diffLines[0], - noteTargetLine: diffLines[0], + ...{ + diffFileHash: diffFile.file_hash, + diffLines, + line: diffLines[1], + range: { start: diffLines[0], end: diffLines[1] }, + noteTargetLine: diffLines[1], + }, + ...(args.props || {}), }, }); }; + const findNoteForm = () => wrapper.findComponent(NoteForm); + describe('methods', () => { beforeEach(() => { wrapper = createComponent(); }); describe('handleCancelCommentForm', () => { + afterEach(() => { + confirmAction.mockReset(); + }); + it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => { - jest.spyOn(window, 'confirm').mockReturnValue(false); + confirmAction.mockResolvedValueOnce(false); - wrapper.vm.handleCancelCommentForm(true, true); + findNoteForm().vm.$emit('cancelForm', true, true); - expect(window.confirm).toHaveBeenCalled(); + expect(confirmAction).toHaveBeenCalled(); }); - it('should ask for confirmation when one of the params false', () => { - jest.spyOn(window, 'confirm').mockReturnValue(false); + it('should not ask for confirmation when one of the params false', () => { + confirmAction.mockResolvedValueOnce(false); - wrapper.vm.handleCancelCommentForm(true, false); + findNoteForm().vm.$emit('cancelForm', true, false); - expect(window.confirm).not.toHaveBeenCalled(); + expect(confirmAction).not.toHaveBeenCalled(); - wrapper.vm.handleCancelCommentForm(false, true); + findNoteForm().vm.$emit('cancelForm', false, true); - expect(window.confirm).not.toHaveBeenCalled(); + expect(confirmAction).not.toHaveBeenCalled(); }); - it('should call cancelCommentForm with lineCode', (done) => { - jest.spyOn(window, 'confirm').mockImplementation(() => {}); + it('should call cancelCommentForm with lineCode', async () => { + confirmAction.mockResolvedValueOnce(true); jest.spyOn(wrapper.vm, 'cancelCommentForm').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'resetAutoSave').mockImplementation(() => {}); - wrapper.vm.handleCancelCommentForm(); - expect(window.confirm).not.toHaveBeenCalled(); - wrapper.vm.$nextTick(() => { - expect(wrapper.vm.cancelCommentForm).toHaveBeenCalledWith({ - lineCode: diffLines[0].line_code, - fileHash: wrapper.vm.diffFileHash, - }); + findNoteForm().vm.$emit('cancelForm', true, true); + + await nextTick(); + + expect(confirmAction).toHaveBeenCalled(); - expect(wrapper.vm.resetAutoSave).toHaveBeenCalled(); + await nextTick(); - done(); + expect(wrapper.vm.cancelCommentForm).toHaveBeenCalledWith({ + lineCode: diffLines[1].line_code, + fileHash: wrapper.vm.diffFileHash, }); + expect(wrapper.vm.resetAutoSave).toHaveBeenCalled(); }); }); @@ -88,13 +107,13 @@ describe('DiffLineNoteForm', () => { start: { line_code: wrapper.vm.commentLineStart.line_code, type: wrapper.vm.commentLineStart.type, - new_line: 1, + new_line: 2, old_line: null, }, end: { line_code: wrapper.vm.line.line_code, type: wrapper.vm.line.type, - new_line: 1, + new_line: 2, old_line: null, }, }; @@ -118,9 +137,25 @@ describe('DiffLineNoteForm', () => { }); }); + describe('created', () => { + it('should use the provided `range` of lines', () => { + wrapper = createComponent(); + + expect(wrapper.vm.lines.start).toBe(diffLines[0]); + expect(wrapper.vm.lines.end).toBe(diffLines[1]); + }); + + it("should fill the internal `lines` data with the provided `line` if there's no provided `range", () => { + wrapper = createComponent({ props: { range: null } }); + + expect(wrapper.vm.lines.start).toBe(diffLines[1]); + expect(wrapper.vm.lines.end).toBe(diffLines[1]); + }); + }); + describe('mounted', () => { it('should init autosave', () => { - const key = 'autosave/Note/Issue/98//DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1'; + const key = 'autosave/Note/Issue/98//DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2'; wrapper = createComponent(); expect(wrapper.vm.autosave).toBeDefined(); diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index f316a9fdf01..31044b0818c 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -113,7 +113,9 @@ describe('Diffs tree list component', () => { wrapper.find('.file-row').trigger('click'); - expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/scrollToFile', 'app/index.js'); + expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/scrollToFile', { + path: 'app/index.js', + }); }); it('renders as file list when renderTreeList is false', () => { diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 85734e05aeb..b5003a54917 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -99,6 +99,10 @@ describe('DiffsStoreActions', () => { const projectPath = '/root/project'; const dismissEndpoint = '/-/user_callouts'; const showSuggestPopover = false; + const mrReviews = { + a: ['z', 'hash:a'], + b: ['y', 'hash:a'], + }; testAction( setBaseConfig, @@ -110,6 +114,7 @@ describe('DiffsStoreActions', () => { projectPath, dismissEndpoint, showSuggestPopover, + mrReviews, }, { endpoint: '', @@ -131,8 +136,21 @@ describe('DiffsStoreActions', () => { projectPath, dismissEndpoint, showSuggestPopover, + mrReviews, }, }, + { + type: types.SET_DIFF_FILE_VIEWED, + payload: { id: 'z', seen: true }, + }, + { + type: types.SET_DIFF_FILE_VIEWED, + payload: { id: 'a', seen: true }, + }, + { + type: types.SET_DIFF_FILE_VIEWED, + payload: { id: 'y', seen: true }, + }, ], [], done, @@ -190,10 +208,10 @@ describe('DiffsStoreActions', () => { { type: types.SET_RETRIEVING_BATCHES, payload: true }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } }, { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' }, - { type: types.VIEW_DIFF_FILE, payload: 'test' }, + { type: types.SET_CURRENT_DIFF_FILE, payload: 'test' }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } }, { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' }, - { type: types.VIEW_DIFF_FILE, payload: 'test2' }, + { type: types.SET_CURRENT_DIFF_FILE, payload: 'test2' }, { type: types.SET_RETRIEVING_BATCHES, payload: false }, { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, ], @@ -307,7 +325,7 @@ describe('DiffsStoreActions', () => { it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => { testAction(setHighlightedRow, 'ABC_123', {}, [ { type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' }, - { type: types.VIEW_DIFF_FILE, payload: 'ABC' }, + { type: types.SET_CURRENT_DIFF_FILE, payload: 'ABC' }, ]); }); }); @@ -890,12 +908,12 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit, getters }, 'path'); + scrollToFile({ state, commit, getters }, { path: 'path' }); expect(document.location.hash).toBe('#test'); }); - it('commits VIEW_DIFF_FILE', () => { + it('commits SET_CURRENT_DIFF_FILE', () => { const state = { treeEntries: { path: { @@ -904,9 +922,9 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit, getters }, 'path'); + scrollToFile({ state, commit, getters }, { path: 'path' }); - expect(commit).toHaveBeenCalledWith(types.VIEW_DIFF_FILE, 'test'); + expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, 'test'); }); }); @@ -1428,7 +1446,7 @@ describe('DiffsStoreActions', () => { }); describe('setCurrentDiffFileIdFromNote', () => { - it('commits VIEW_DIFF_FILE', () => { + it('commits SET_CURRENT_DIFF_FILE', () => { const commit = jest.fn(); const state = { diffFiles: [{ file_hash: '123' }] }; const rootGetters = { @@ -1438,10 +1456,10 @@ describe('DiffsStoreActions', () => { setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); - expect(commit).toHaveBeenCalledWith(types.VIEW_DIFF_FILE, '123'); + expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, '123'); }); - it('does not commit VIEW_DIFF_FILE when discussion has no diff_file', () => { + it('does not commit SET_CURRENT_DIFF_FILE when discussion has no diff_file', () => { const commit = jest.fn(); const state = { diffFiles: [{ file_hash: '123' }] }; const rootGetters = { @@ -1454,7 +1472,7 @@ describe('DiffsStoreActions', () => { expect(commit).not.toHaveBeenCalled(); }); - it('does not commit VIEW_DIFF_FILE when diff file does not exist', () => { + it('does not commit SET_CURRENT_DIFF_FILE when diff file does not exist', () => { const commit = jest.fn(); const state = { diffFiles: [{ file_hash: '123' }] }; const rootGetters = { @@ -1469,12 +1487,12 @@ describe('DiffsStoreActions', () => { }); describe('navigateToDiffFileIndex', () => { - it('commits VIEW_DIFF_FILE', (done) => { + it('commits SET_CURRENT_DIFF_FILE', (done) => { testAction( navigateToDiffFileIndex, 0, { diffFiles: [{ file_hash: '123' }] }, - [{ type: types.VIEW_DIFF_FILE, payload: '123' }], + [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], [], done, ); @@ -1523,13 +1541,14 @@ describe('DiffsStoreActions', () => { describe('reviewFile', () => { const file = { id: '123', + file_hash: 'xyz', file_identifier_hash: 'abc', load_collapsed_diff_url: 'gitlab-org/gitlab-test/-/merge_requests/1/diffs', }; it.each` - reviews | diffFile | reviewed - ${{ abc: ['123'] }} | ${file} | ${true} - ${{}} | ${file} | ${false} + reviews | diffFile | reviewed + ${{ abc: ['123', 'hash:xyz'] }} | ${file} | ${true} + ${{}} | ${file} | ${false} `( 'sets reviews ($reviews) to localStorage and state for file $file if it is marked reviewed=$reviewed', ({ reviews, diffFile, reviewed }) => { diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index fc9ba223d5a..c104fcd5fb9 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -633,16 +633,36 @@ describe('DiffsStoreMutations', () => { }); }); - describe('VIEW_DIFF_FILE', () => { + describe('SET_CURRENT_DIFF_FILE', () => { it('updates currentDiffFileId', () => { const state = createState(); - mutations[types.VIEW_DIFF_FILE](state, 'somefileid'); + mutations[types.SET_CURRENT_DIFF_FILE](state, 'somefileid'); expect(state.currentDiffFileId).toBe('somefileid'); }); }); + describe('SET_DIFF_FILE_VIEWED', () => { + let state; + + beforeEach(() => { + state = { + viewedDiffFileIds: { 123: true }, + }; + }); + + it.each` + id | bool | outcome + ${'abc'} | ${true} | ${{ 123: true, abc: true }} + ${'123'} | ${false} | ${{ 123: false }} + `('sets the viewed files list to $bool for the id $id', ({ id, bool, outcome }) => { + mutations[types.SET_DIFF_FILE_VIEWED](state, { id, seen: bool }); + + expect(state.viewedDiffFileIds).toEqual(outcome); + }); + }); + describe('Set highlighted row', () => { it('sets highlighted row', () => { const state = createState(); diff --git a/spec/frontend/diffs/utils/diff_line_spec.js b/spec/frontend/diffs/utils/diff_line_spec.js new file mode 100644 index 00000000000..adcb4a4433c --- /dev/null +++ b/spec/frontend/diffs/utils/diff_line_spec.js @@ -0,0 +1,30 @@ +import { pickDirection } from '~/diffs/utils/diff_line'; + +describe('diff_line utilities', () => { + describe('pickDirection', () => { + const left = { + line_code: 'left', + }; + const right = { + line_code: 'right', + }; + const defaultLine = { + left, + right, + }; + + it.each` + code | pick | line | pickDescription + ${'left'} | ${left} | ${defaultLine} | ${'the left line'} + ${'right'} | ${right} | ${defaultLine} | ${'the right line'} + ${'junk'} | ${left} | ${defaultLine} | ${'the default: the left line'} + ${'junk'} | ${right} | ${{ right }} | ${"the right line if there's no left line to default to"} + ${'right'} | ${left} | ${{ left }} | ${"the left line when there isn't a right line to match"} + `( + 'when provided a line and a line code `$code`, picks $pickDescription', + ({ code, line, pick }) => { + expect(pickDirection({ line, code })).toBe(pick); + }, + ); + }); +}); diff --git a/spec/frontend/diffs/utils/discussions_spec.js b/spec/frontend/diffs/utils/discussions_spec.js new file mode 100644 index 00000000000..9a3d442d943 --- /dev/null +++ b/spec/frontend/diffs/utils/discussions_spec.js @@ -0,0 +1,133 @@ +import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; + +describe('Diff Discussions Utils', () => { + describe('discussionIntersectionObserverHandlerFactory', () => { + it('creates a handler function', () => { + expect(discussionIntersectionObserverHandlerFactory()).toBeInstanceOf(Function); + }); + + describe('intersection observer handler', () => { + const functions = { + setCurrentDiscussionId: jest.fn(), + getPreviousUnresolvedDiscussionId: jest.fn().mockImplementation((id) => { + return Number(id) - 1; + }), + }; + const defaultProcessableWrapper = { + entry: { + time: 0, + isIntersecting: true, + rootBounds: { + bottom: 0, + }, + boundingClientRect: { + top: 0, + }, + }, + currentDiscussion: { + id: 1, + }, + isFirstUnresolved: false, + isDiffsPage: true, + }; + let handler; + let getMock; + let setMock; + + beforeEach(() => { + functions.setCurrentDiscussionId.mockClear(); + functions.getPreviousUnresolvedDiscussionId.mockClear(); + + defaultProcessableWrapper.functions = functions; + + setMock = functions.setCurrentDiscussionId.mock; + getMock = functions.getPreviousUnresolvedDiscussionId.mock; + handler = discussionIntersectionObserverHandlerFactory(); + }); + + it('debounces multiple simultaneous requests into one queue', () => { + handler(defaultProcessableWrapper); + handler(defaultProcessableWrapper); + handler(defaultProcessableWrapper); + handler(defaultProcessableWrapper); + + expect(setTimeout).toHaveBeenCalledTimes(4); + expect(clearTimeout).toHaveBeenCalledTimes(3); + + // By only advancing to one timer, we ensure it's all being batched into one queue + jest.advanceTimersToNextTimer(); + + expect(functions.setCurrentDiscussionId).toHaveBeenCalledTimes(4); + }); + + it('properly processes, sorts and executes the correct actions for a set of observed intersections', () => { + handler(defaultProcessableWrapper); + handler({ + // This observation is here to be filtered out because it's a scrollDown + ...defaultProcessableWrapper, + entry: { + ...defaultProcessableWrapper.entry, + isIntersecting: false, + boundingClientRect: { top: 10 }, + rootBounds: { bottom: 100 }, + }, + }); + handler({ + ...defaultProcessableWrapper, + entry: { + ...defaultProcessableWrapper.entry, + time: 101, + isIntersecting: false, + rootBounds: { bottom: -100 }, + }, + currentDiscussion: { id: 20 }, + }); + handler({ + ...defaultProcessableWrapper, + entry: { + ...defaultProcessableWrapper.entry, + time: 100, + isIntersecting: false, + boundingClientRect: { top: 100 }, + }, + currentDiscussion: { id: 30 }, + isDiffsPage: false, + }); + handler({ + ...defaultProcessableWrapper, + isFirstUnresolved: true, + entry: { + ...defaultProcessableWrapper.entry, + time: 100, + isIntersecting: false, + boundingClientRect: { top: 200 }, + }, + }); + + jest.advanceTimersToNextTimer(); + + expect(setMock.calls.length).toBe(4); + expect(setMock.calls[0]).toEqual([1]); + expect(setMock.calls[1]).toEqual([29]); + expect(setMock.calls[2]).toEqual([null]); + expect(setMock.calls[3]).toEqual([19]); + + expect(getMock.calls.length).toBe(2); + expect(getMock.calls[0]).toEqual([30, false]); + expect(getMock.calls[1]).toEqual([20, true]); + + [ + setMock.invocationCallOrder[0], + getMock.invocationCallOrder[0], + setMock.invocationCallOrder[1], + setMock.invocationCallOrder[2], + getMock.invocationCallOrder[1], + setMock.invocationCallOrder[3], + ].forEach((order, idx, list) => { + // Compare each invocation sequence to the one before it (except the first one) + expect(list[idx - 1] || -1).toBeLessThan(order); + }); + }); + }); + }); +}); diff --git a/spec/frontend/diffs/utils/file_reviews_spec.js b/spec/frontend/diffs/utils/file_reviews_spec.js index 230ec12409c..ccd27a5ae3e 100644 --- a/spec/frontend/diffs/utils/file_reviews_spec.js +++ b/spec/frontend/diffs/utils/file_reviews_spec.js @@ -11,14 +11,14 @@ import { function getDefaultReviews() { return { - abc: ['123', '098'], + abc: ['123', 'hash:xyz', '098', 'hash:uvw'], }; } describe('File Review(s) utilities', () => { const mrPath = 'my/fake/mr/42'; const storageKey = `${mrPath}-file-reviews`; - const file = { id: '123', file_identifier_hash: 'abc' }; + const file = { id: '123', file_hash: 'xyz', file_identifier_hash: 'abc' }; const storedValue = JSON.stringify(getDefaultReviews()); let reviews; @@ -44,14 +44,14 @@ describe('File Review(s) utilities', () => { }); describe('reviewStatuses', () => { - const file1 = { id: '123', file_identifier_hash: 'abc' }; - const file2 = { id: '098', file_identifier_hash: 'abc' }; + const file1 = { id: '123', hash: 'xyz', file_identifier_hash: 'abc' }; + const file2 = { id: '098', hash: 'uvw', file_identifier_hash: 'abc' }; it.each` mrReviews | files | fileReviews ${{}} | ${[file1, file2]} | ${{ 123: false, '098': false }} - ${{ abc: ['123'] }} | ${[file1, file2]} | ${{ 123: true, '098': false }} - ${{ abc: ['098'] }} | ${[file1, file2]} | ${{ 123: false, '098': true }} + ${{ abc: ['123', 'hash:xyz'] }} | ${[file1, file2]} | ${{ 123: true, '098': false }} + ${{ abc: ['098', 'hash:uvw'] }} | ${[file1, file2]} | ${{ 123: false, '098': true }} ${{ def: ['123'] }} | ${[file1, file2]} | ${{ 123: false, '098': false }} ${{ abc: ['123'], def: ['098'] }} | ${[]} | ${{}} `( @@ -128,7 +128,7 @@ describe('File Review(s) utilities', () => { describe('markFileReview', () => { it("adds a review when there's nothing that already exists", () => { - expect(markFileReview(null, file)).toStrictEqual({ abc: ['123'] }); + expect(markFileReview(null, file)).toStrictEqual({ abc: ['123', 'hash:xyz'] }); }); it("overwrites an existing review if it's for the same file (identifier hash)", () => { @@ -136,15 +136,15 @@ describe('File Review(s) utilities', () => { }); it('removes a review from the list when `reviewed` is `false`', () => { - expect(markFileReview(reviews, file, false)).toStrictEqual({ abc: ['098'] }); + expect(markFileReview(reviews, file, false)).toStrictEqual({ abc: ['098', 'hash:uvw'] }); }); it('adds a new review if the file ID is new', () => { - const updatedFile = { ...file, id: '098' }; - const allReviews = markFileReview({ abc: ['123'] }, updatedFile); + const updatedFile = { ...file, id: '098', file_hash: 'uvw' }; + const allReviews = markFileReview({ abc: ['123', 'hash:xyz'] }, updatedFile); expect(allReviews).toStrictEqual(getDefaultReviews()); - expect(allReviews.abc).toStrictEqual(['123', '098']); + expect(allReviews.abc).toStrictEqual(['123', 'hash:xyz', '098', 'hash:uvw']); }); it.each` @@ -158,7 +158,7 @@ describe('File Review(s) utilities', () => { it('removes the file key if there are no more reviews for it', () => { let updated = markFileReview(reviews, file, false); - updated = markFileReview(updated, { ...file, id: '098' }, false); + updated = markFileReview(updated, { ...file, id: '098', file_hash: 'uvw' }, false); expect(updated).toStrictEqual({}); }); diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index acf7d0780cd..12e10f7c5f4 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -71,6 +71,7 @@ describe('dropzone_input', () => { triggerPasteEvent({ types: ['text/plain', 'text/html', 'text/rtf', 'Files'], getData: () => longFileName, + files: [new File([new Blob()], longFileName, { type: 'image/png' })], items: [ { kind: 'file', @@ -84,6 +85,24 @@ describe('dropzone_input', () => { await waitForPromises(); expect(axiosMock.history.post[0].data.get('file').name).toHaveLength(246); }); + + it('display original file name in comment box', async () => { + const axiosMock = new MockAdapter(axios); + triggerPasteEvent({ + types: ['Files'], + files: [new File([new Blob()], 'test.png', { type: 'image/png' })], + items: [ + { + kind: 'file', + type: 'image/png', + getAsFile: () => new Blob(), + }, + ], + }); + axiosMock.onPost().reply(httpStatusCodes.OK, { link: { markdown: 'foo' } }); + await waitForPromises(); + expect(axiosMock.history.post[0].data.get('file').name).toEqual('test.png'); + }); }); describe('shows error message', () => { diff --git a/spec/frontend/editor/helpers.js b/spec/frontend/editor/helpers.js new file mode 100644 index 00000000000..6f7cdf6efb3 --- /dev/null +++ b/spec/frontend/editor/helpers.js @@ -0,0 +1,53 @@ +export class MyClassExtension { + // eslint-disable-next-line class-methods-use-this + provides() { + return { + shared: () => 'extension', + classExtMethod: () => 'class own method', + }; + } +} + +export function MyFnExtension() { + return { + fnExtMethod: () => 'fn own method', + provides: () => { + return { + fnExtMethod: () => 'class own method', + }; + }, + }; +} + +export const MyConstExt = () => { + return { + provides: () => { + return { + constExtMethod: () => 'const own method', + }; + }, + }; +}; + +export const conflictingExtensions = { + WithInstanceExt: () => { + return { + provides: () => { + return { + use: () => 'A conflict with instance', + ownMethod: () => 'Non-conflicting method', + }; + }, + }; + }, + WithAnotherExt: () => { + return { + provides: () => { + return { + shared: () => 'A conflict with extension', + ownMethod: () => 'Non-conflicting method', + }; + }, + }; + }, +}; diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index 2c06ae03892..a0fb1178b3b 100644 --- a/spec/frontend/editor/source_editor_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -148,7 +148,10 @@ describe('The basis for an Source Editor extension', () => { revealLineInCenter: revealSpy, deltaDecorations: decorationsSpy, }; - const defaultDecorationOptions = { isWholeLine: true, className: 'active-line-text' }; + const defaultDecorationOptions = { + isWholeLine: true, + className: 'active-line-text', + }; useFakeRequestAnimationFrame(); @@ -157,18 +160,22 @@ describe('The basis for an Source Editor extension', () => { }); it.each` - desc | hash | shouldReveal | expectedRange - ${'properly decorates a single line'} | ${'#L10'} | ${true} | ${[10, 1, 10, 1]} - ${'properly decorates multiple lines'} | ${'#L7-42'} | ${true} | ${[7, 1, 42, 1]} - ${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${true} | ${[7, 1, 42, 1]} - ${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${true} | ${[7, 1, 7, 1]} - ${'does not highlight if there is no hash'} | ${''} | ${false} | ${null} - ${'does not highlight if the hash is undefined'} | ${undefined} | ${false} | ${null} - ${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${false} | ${null} - ${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${false} | ${null} - `('$desc', ({ hash, shouldReveal, expectedRange } = {}) => { + desc | hash | bounds | shouldReveal | expectedRange + ${'properly decorates a single line'} | ${'#L10'} | ${undefined} | ${true} | ${[10, 1, 10, 1]} + ${'properly decorates multiple lines'} | ${'#L7-42'} | ${undefined} | ${true} | ${[7, 1, 42, 1]} + ${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${undefined} | ${true} | ${[7, 1, 42, 1]} + ${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${undefined} | ${true} | ${[7, 1, 7, 1]} + ${'does not highlight if there is no hash'} | ${''} | ${undefined} | ${false} | ${null} + ${'does not highlight if the hash is undefined'} | ${undefined} | ${undefined} | ${false} | ${null} + ${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${undefined} | ${false} | ${null} + ${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${undefined} | ${false} | ${null} + ${'highlights lines if bounds are passed'} | ${undefined} | ${[17, 42]} | ${true} | ${[17, 1, 42, 1]} + ${'highlights one line if bounds has a single value'} | ${undefined} | ${[17]} | ${true} | ${[17, 1, 17, 1]} + ${'does not highlight if bounds is invalid'} | ${undefined} | ${[Number.NaN]} | ${false} | ${null} + ${'uses bounds if both hash and bounds exist'} | ${'#L7-42'} | ${[3, 5]} | ${true} | ${[3, 1, 5, 1]} + `('$desc', ({ hash, bounds, shouldReveal, expectedRange } = {}) => { window.location.hash = hash; - SourceEditorExtension.highlightLines(instance); + SourceEditorExtension.highlightLines(instance, bounds); if (!shouldReveal) { expect(revealSpy).not.toHaveBeenCalled(); expect(decorationsSpy).not.toHaveBeenCalled(); @@ -193,6 +200,43 @@ describe('The basis for an Source Editor extension', () => { SourceEditorExtension.highlightLines(instance); expect(instance.lineDecorations).toBe('foo'); }); + + it('replaces existing line highlights', () => { + const oldLineDecorations = [ + { + range: new Range(1, 1, 20, 1), + options: { isWholeLine: true, className: 'active-line-text' }, + }, + ]; + const newLineDecorations = [ + { + range: new Range(7, 1, 10, 1), + options: { isWholeLine: true, className: 'active-line-text' }, + }, + ]; + instance.lineDecorations = oldLineDecorations; + SourceEditorExtension.highlightLines(instance, [7, 10]); + expect(decorationsSpy).toHaveBeenCalledWith(oldLineDecorations, newLineDecorations); + }); + }); + + describe('removeHighlights', () => { + const decorationsSpy = jest.fn(); + const lineDecorations = [ + { + range: new Range(1, 1, 20, 1), + options: { isWholeLine: true, className: 'active-line-text' }, + }, + ]; + const instance = { + deltaDecorations: decorationsSpy, + lineDecorations, + }; + + it('removes all existing decorations', () => { + SourceEditorExtension.removeHighlights(instance); + expect(decorationsSpy).toHaveBeenCalledWith(lineDecorations, []); + }); }); describe('setupLineLinking', () => { diff --git a/spec/frontend/editor/source_editor_extension_spec.js b/spec/frontend/editor/source_editor_extension_spec.js new file mode 100644 index 00000000000..6f2eb07a043 --- /dev/null +++ b/spec/frontend/editor/source_editor_extension_spec.js @@ -0,0 +1,65 @@ +import EditorExtension from '~/editor/source_editor_extension'; +import { EDITOR_EXTENSION_DEFINITION_ERROR } from '~/editor/constants'; +import * as helpers from './helpers'; + +describe('Editor Extension', () => { + const dummyObj = { foo: 'bar' }; + + it.each` + definition | setupOptions + ${undefined} | ${undefined} + ${undefined} | ${{}} + ${undefined} | ${dummyObj} + ${{}} | ${dummyObj} + ${dummyObj} | ${dummyObj} + `( + 'throws when definition = $definition and setupOptions = $setupOptions', + ({ definition, setupOptions }) => { + const constructExtension = () => new EditorExtension({ definition, setupOptions }); + expect(constructExtension).toThrowError(EDITOR_EXTENSION_DEFINITION_ERROR); + }, + ); + + it.each` + definition | setupOptions | expectedName + ${helpers.MyClassExtension} | ${undefined} | ${'MyClassExtension'} + ${helpers.MyClassExtension} | ${{}} | ${'MyClassExtension'} + ${helpers.MyClassExtension} | ${dummyObj} | ${'MyClassExtension'} + ${helpers.MyFnExtension} | ${undefined} | ${'MyFnExtension'} + ${helpers.MyFnExtension} | ${{}} | ${'MyFnExtension'} + ${helpers.MyFnExtension} | ${dummyObj} | ${'MyFnExtension'} + ${helpers.MyConstExt} | ${undefined} | ${'MyConstExt'} + ${helpers.MyConstExt} | ${{}} | ${'MyConstExt'} + ${helpers.MyConstExt} | ${dummyObj} | ${'MyConstExt'} + `( + 'correctly creates extension for definition = $definition and setupOptions = $setupOptions', + ({ definition, setupOptions, expectedName }) => { + const extension = new EditorExtension({ definition, setupOptions }); + // eslint-disable-next-line new-cap + const constructedDefinition = new definition(); + + expect(extension).toEqual( + expect.objectContaining({ + name: expectedName, + setupOptions, + }), + ); + expect(extension.obj.constructor.prototype).toBe(constructedDefinition.constructor.prototype); + }, + ); + + describe('api', () => { + it.each` + definition | expectedKeys + ${helpers.MyClassExtension} | ${['shared', 'classExtMethod']} + ${helpers.MyFnExtension} | ${['fnExtMethod']} + ${helpers.MyConstExt} | ${['constExtMethod']} + `('correctly returns API for $definition', ({ definition, expectedKeys }) => { + const extension = new EditorExtension({ definition }); + const expectedApi = Object.fromEntries( + expectedKeys.map((key) => [key, expect.any(Function)]), + ); + expect(extension.api).toEqual(expect.objectContaining(expectedApi)); + }); + }); +}); diff --git a/spec/frontend/editor/source_editor_instance_spec.js b/spec/frontend/editor/source_editor_instance_spec.js new file mode 100644 index 00000000000..87b20a4ba73 --- /dev/null +++ b/spec/frontend/editor/source_editor_instance_spec.js @@ -0,0 +1,387 @@ +import { editor as monacoEditor } from 'monaco-editor'; +import { + EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, + EDITOR_EXTENSION_NO_DEFINITION_ERROR, + EDITOR_EXTENSION_DEFINITION_TYPE_ERROR, + EDITOR_EXTENSION_NOT_REGISTERED_ERROR, + EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR, +} from '~/editor/constants'; +import Instance from '~/editor/source_editor_instance'; +import { sprintf } from '~/locale'; +import { MyClassExtension, conflictingExtensions, MyFnExtension, MyConstExt } from './helpers'; + +describe('Source Editor Instance', () => { + let seInstance; + + const defSetupOptions = { foo: 'bar' }; + const fullExtensionsArray = [ + { definition: MyClassExtension }, + { definition: MyFnExtension }, + { definition: MyConstExt }, + ]; + const fullExtensionsArrayWithOptions = [ + { definition: MyClassExtension, setupOptions: defSetupOptions }, + { definition: MyFnExtension, setupOptions: defSetupOptions }, + { definition: MyConstExt, setupOptions: defSetupOptions }, + ]; + + const fooFn = jest.fn(); + class DummyExt { + // eslint-disable-next-line class-methods-use-this + provides() { + return { + fooFn, + }; + } + } + + afterEach(() => { + seInstance = undefined; + }); + + it('sets up the registry for the methods coming from extensions', () => { + seInstance = new Instance(); + expect(seInstance.methods).toBeDefined(); + + seInstance.use({ definition: MyClassExtension }); + expect(seInstance.methods).toEqual({ + shared: 'MyClassExtension', + classExtMethod: 'MyClassExtension', + }); + + seInstance.use({ definition: MyFnExtension }); + expect(seInstance.methods).toEqual({ + shared: 'MyClassExtension', + classExtMethod: 'MyClassExtension', + fnExtMethod: 'MyFnExtension', + }); + }); + + describe('proxy', () => { + it('returns prop from an extension if extension provides it', () => { + seInstance = new Instance(); + seInstance.use({ definition: DummyExt }); + + expect(fooFn).not.toHaveBeenCalled(); + seInstance.fooFn(); + expect(fooFn).toHaveBeenCalled(); + }); + + it('returns props from SE instance itself if no extension provides the prop', () => { + seInstance = new Instance({ + use: fooFn, + }); + jest.spyOn(seInstance, 'use').mockImplementation(() => {}); + expect(seInstance.use).not.toHaveBeenCalled(); + expect(fooFn).not.toHaveBeenCalled(); + seInstance.use(); + expect(seInstance.use).toHaveBeenCalled(); + expect(fooFn).not.toHaveBeenCalled(); + }); + + it('returns props from Monaco instance when the prop does not exist on the SE instance', () => { + seInstance = new Instance({ + fooFn, + }); + + expect(fooFn).not.toHaveBeenCalled(); + seInstance.fooFn(); + expect(fooFn).toHaveBeenCalled(); + }); + }); + + describe('public API', () => { + it.each(['use', 'unuse'], 'provides "%s" as public method by default', (method) => { + seInstance = new Instance(); + expect(seInstance[method]).toBeDefined(); + }); + + describe('use', () => { + it('extends the SE instance with methods provided by an extension', () => { + seInstance = new Instance(); + seInstance.use({ definition: DummyExt }); + + expect(fooFn).not.toHaveBeenCalled(); + seInstance.fooFn(); + expect(fooFn).toHaveBeenCalled(); + }); + + it.each` + extensions | expectedProps + ${{ definition: MyClassExtension }} | ${['shared', 'classExtMethod']} + ${{ definition: MyFnExtension }} | ${['fnExtMethod']} + ${{ definition: MyConstExt }} | ${['constExtMethod']} + ${fullExtensionsArray} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']} + ${fullExtensionsArrayWithOptions} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']} + `( + 'Should register $expectedProps when extension is "$extensions"', + ({ extensions, expectedProps }) => { + seInstance = new Instance(); + expect(seInstance.extensionsAPI).toHaveLength(0); + + seInstance.use(extensions); + + expect(seInstance.extensionsAPI).toEqual(expectedProps); + }, + ); + + it.each` + definition | preInstalledExtDefinition | expectedErrorProp + ${conflictingExtensions.WithInstanceExt} | ${MyClassExtension} | ${'use'} + ${conflictingExtensions.WithInstanceExt} | ${null} | ${'use'} + ${conflictingExtensions.WithAnotherExt} | ${null} | ${undefined} + ${conflictingExtensions.WithAnotherExt} | ${MyClassExtension} | ${'shared'} + ${MyClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'} + `( + 'logs the naming conflict error when registering $definition', + ({ definition, preInstalledExtDefinition, expectedErrorProp }) => { + seInstance = new Instance(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + if (preInstalledExtDefinition) { + seInstance.use({ definition: preInstalledExtDefinition }); + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled(); + } + + seInstance.use({ definition }); + + if (expectedErrorProp) { + // eslint-disable-next-line no-console + expect(console.error).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining( + sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop: expectedErrorProp }), + ), + ); + } else { + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled(); + } + }, + ); + + it.each` + extensions | thrownError + ${''} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR} + ${undefined} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR} + ${{}} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR} + ${{ foo: 'bar' }} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR} + ${{ definition: '' }} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR} + ${{ definition: undefined }} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR} + ${{ definition: [] }} | ${EDITOR_EXTENSION_DEFINITION_TYPE_ERROR} + ${{ definition: {} }} | ${EDITOR_EXTENSION_DEFINITION_TYPE_ERROR} + ${{ definition: { foo: 'bar' } }} | ${EDITOR_EXTENSION_DEFINITION_TYPE_ERROR} + `( + 'Should throw $thrownError when extension is "$extensions"', + ({ extensions, thrownError }) => { + seInstance = new Instance(); + const useExtension = () => { + seInstance.use(extensions); + }; + expect(useExtension).toThrowError(thrownError); + }, + ); + + describe('global extensions registry', () => { + let extensionStore; + + beforeEach(() => { + extensionStore = new Map(); + seInstance = new Instance({}, extensionStore); + }); + + it('stores _instances_ of the used extensions in a global registry', () => { + const extension = seInstance.use({ definition: MyClassExtension }); + + expect(extensionStore.size).toBe(1); + expect(extensionStore.entries().next().value).toEqual(['MyClassExtension', extension]); + }); + + it('does not duplicate entries in the registry', () => { + jest.spyOn(extensionStore, 'set'); + + const extension1 = seInstance.use({ definition: MyClassExtension }); + seInstance.use({ definition: MyClassExtension }); + + expect(extensionStore.set).toHaveBeenCalledTimes(1); + expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1); + }); + + it.each` + desc | currentSetupOptions | newSetupOptions | expectedCallTimes + ${'updates'} | ${undefined} | ${defSetupOptions} | ${2} + ${'updates'} | ${defSetupOptions} | ${undefined} | ${2} + ${'updates'} | ${{ foo: 'bar' }} | ${{ foo: 'new' }} | ${2} + ${'does not update'} | ${undefined} | ${undefined} | ${1} + ${'does not update'} | ${{}} | ${{}} | ${1} + ${'does not update'} | ${defSetupOptions} | ${defSetupOptions} | ${1} + `( + '$desc the extensions entry when setupOptions "$currentSetupOptions" get changed to "$newSetupOptions"', + ({ currentSetupOptions, newSetupOptions, expectedCallTimes }) => { + jest.spyOn(extensionStore, 'set'); + + const extension1 = seInstance.use({ + definition: MyClassExtension, + setupOptions: currentSetupOptions, + }); + const extension2 = seInstance.use({ + definition: MyClassExtension, + setupOptions: newSetupOptions, + }); + + expect(extensionStore.size).toBe(1); + expect(extensionStore.set).toHaveBeenCalledTimes(expectedCallTimes); + if (expectedCallTimes > 1) { + expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension2); + } else { + expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1); + } + }, + ); + }); + }); + + describe('unuse', () => { + it.each` + unuseExtension | thrownError + ${undefined} | ${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR} + ${''} | ${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR} + ${{}} | ${sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name: '' })} + ${[]} | ${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR} + `( + `Should throw "${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}" when extension is "$unuseExtension"`, + ({ unuseExtension, thrownError }) => { + seInstance = new Instance(); + const unuse = () => { + seInstance.unuse(unuseExtension); + }; + expect(unuse).toThrowError(thrownError); + }, + ); + + it.each` + initExtensions | unuseExtensionIndex | remainingAPI + ${{ definition: MyClassExtension }} | ${0} | ${[]} + ${{ definition: MyFnExtension }} | ${0} | ${[]} + ${{ definition: MyConstExt }} | ${0} | ${[]} + ${fullExtensionsArray} | ${0} | ${['fnExtMethod', 'constExtMethod']} + ${fullExtensionsArray} | ${1} | ${['shared', 'classExtMethod', 'constExtMethod']} + ${fullExtensionsArray} | ${2} | ${['shared', 'classExtMethod', 'fnExtMethod']} + `( + 'un-registers properties introduced by single extension $unuseExtension', + ({ initExtensions, unuseExtensionIndex, remainingAPI }) => { + seInstance = new Instance(); + const extensions = seInstance.use(initExtensions); + + if (Array.isArray(initExtensions)) { + seInstance.unuse(extensions[unuseExtensionIndex]); + } else { + seInstance.unuse(extensions); + } + expect(seInstance.extensionsAPI).toEqual(remainingAPI); + }, + ); + + it.each` + unuseExtensionIndex | remainingAPI + ${[0, 1]} | ${['constExtMethod']} + ${[0, 2]} | ${['fnExtMethod']} + ${[1, 2]} | ${['shared', 'classExtMethod']} + `( + 'un-registers properties introduced by multiple extensions $unuseExtension', + ({ unuseExtensionIndex, remainingAPI }) => { + seInstance = new Instance(); + const extensions = seInstance.use(fullExtensionsArray); + const extensionsToUnuse = extensions.filter((ext, index) => + unuseExtensionIndex.includes(index), + ); + + seInstance.unuse(extensionsToUnuse); + expect(seInstance.extensionsAPI).toEqual(remainingAPI); + }, + ); + + it('it does not remove entry from the global registry to keep for potential future re-use', () => { + const extensionStore = new Map(); + seInstance = new Instance({}, extensionStore); + const extensions = seInstance.use(fullExtensionsArray); + const verifyExpectations = () => { + const entries = extensionStore.entries(); + const mockExtensions = ['MyClassExtension', 'MyFnExtension', 'MyConstExt']; + expect(extensionStore.size).toBe(mockExtensions.length); + mockExtensions.forEach((ext, index) => { + expect(entries.next().value).toEqual([ext, extensions[index]]); + }); + }; + + verifyExpectations(); + seInstance.unuse(extensions); + verifyExpectations(); + }); + }); + + describe('updateModelLanguage', () => { + let instanceModel; + + beforeEach(() => { + instanceModel = monacoEditor.createModel(''); + seInstance = new Instance({ + getModel: () => instanceModel, + }); + }); + + it.each` + path | expectedLanguage + ${'foo.js'} | ${'javascript'} + ${'foo.md'} | ${'markdown'} + ${'foo.rb'} | ${'ruby'} + ${''} | ${'plaintext'} + ${undefined} | ${'plaintext'} + ${'test.nonexistingext'} | ${'plaintext'} + `( + 'changes language of an attached model to "$expectedLanguage" when filepath is "$path"', + ({ path, expectedLanguage }) => { + seInstance.updateModelLanguage(path); + expect(instanceModel.getLanguageIdentifier().language).toBe(expectedLanguage); + }, + ); + }); + + describe('extensions life-cycle callbacks', () => { + const onSetup = jest.fn().mockImplementation(() => {}); + const onUse = jest.fn().mockImplementation(() => {}); + const onBeforeUnuse = jest.fn().mockImplementation(() => {}); + const onUnuse = jest.fn().mockImplementation(() => {}); + const MyFullExtWithCallbacks = () => { + return { + onSetup, + onUse, + onBeforeUnuse, + onUnuse, + }; + }; + + it('passes correct arguments to callback fns when using an extension', () => { + seInstance = new Instance(); + seInstance.use({ + definition: MyFullExtWithCallbacks, + setupOptions: defSetupOptions, + }); + expect(onSetup).toHaveBeenCalledWith(defSetupOptions, seInstance); + expect(onUse).toHaveBeenCalledWith(seInstance); + }); + + it('passes correct arguments to callback fns when un-using an extension', () => { + seInstance = new Instance(); + const extension = seInstance.use({ + definition: MyFullExtWithCallbacks, + setupOptions: defSetupOptions, + }); + seInstance.unuse(extension); + expect(onBeforeUnuse).toHaveBeenCalledWith(seInstance); + expect(onUnuse).toHaveBeenCalledWith(seInstance); + }); + }); + }); +}); diff --git a/spec/frontend/editor/source_editor_yaml_ext_spec.js b/spec/frontend/editor/source_editor_yaml_ext_spec.js new file mode 100644 index 00000000000..97d2b0b21d0 --- /dev/null +++ b/spec/frontend/editor/source_editor_yaml_ext_spec.js @@ -0,0 +1,449 @@ +import { Document } from 'yaml'; +import SourceEditor from '~/editor/source_editor'; +import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; + +const getEditorInstance = (editorInstanceOptions = {}) => { + setFixtures('
'); + return new SourceEditor().createInstance({ + el: document.getElementById('editor'), + blobPath: '.gitlab-ci.yml', + language: 'yaml', + ...editorInstanceOptions, + }); +}; + +const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => { + setFixtures('
'); + const instance = getEditorInstance(editorInstanceOptions); + instance.use(new YamlEditorExtension({ instance, ...extensionOptions })); + + // Remove the below once + // https://gitlab.com/gitlab-org/gitlab/-/issues/325992 is resolved + if (editorInstanceOptions.value && !extensionOptions.model) { + instance.setValue(editorInstanceOptions.value); + } + + return instance; +}; + +describe('YamlCreatorExtension', () => { + describe('constructor', () => { + it('saves constructor options', () => { + const instance = getEditorInstanceWithExtension({ + highlightPath: 'foo', + enableComments: true, + }); + expect(instance).toEqual( + expect.objectContaining({ + options: expect.objectContaining({ + highlightPath: 'foo', + enableComments: true, + }), + }), + ); + }); + + it('dumps values loaded with the model constructor options', () => { + const model = { foo: 'bar' }; + const expected = 'foo: bar\n'; + const instance = getEditorInstanceWithExtension({ model }); + expect(instance.getDoc().get('foo')).toBeDefined(); + expect(instance.getValue()).toEqual(expected); + }); + + it('registers the onUpdate() function', () => { + const instance = getEditorInstance(); + const onDidChangeModelContent = jest.spyOn(instance, 'onDidChangeModelContent'); + instance.use(new YamlEditorExtension({ instance })); + expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("If not provided with a load constructor option, it will parse the editor's value", () => { + const editorValue = 'foo: bar'; + const instance = getEditorInstanceWithExtension({}, { value: editorValue }); + expect(instance.getDoc().get('foo')).toBeDefined(); + }); + + it("Prefers values loaded with the load constructor option over the editor's existing value", () => { + const editorValue = 'oldValue: this should be overriden'; + const model = { thisShould: 'be the actual value' }; + const expected = 'thisShould: be the actual value\n'; + const instance = getEditorInstanceWithExtension({ model }, { value: editorValue }); + expect(instance.getDoc().get('oldValue')).toBeUndefined(); + expect(instance.getValue()).toEqual(expected); + }); + }); + + describe('initFromModel', () => { + const model = { foo: 'bar', 1: 2, abc: ['def'] }; + const doc = new Document(model); + + it('should call transformComments if enableComments is true', () => { + const instance = getEditorInstanceWithExtension({ enableComments: true }); + const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments'); + YamlEditorExtension.initFromModel(instance, model); + expect(transformComments).toHaveBeenCalled(); + }); + + it('should not call transformComments if enableComments is false', () => { + const instance = getEditorInstanceWithExtension({ enableComments: false }); + const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments'); + YamlEditorExtension.initFromModel(instance, model); + expect(transformComments).not.toHaveBeenCalled(); + }); + + it('should call setValue with the stringified model', () => { + const instance = getEditorInstanceWithExtension(); + const setValue = jest.spyOn(instance, 'setValue'); + YamlEditorExtension.initFromModel(instance, model); + expect(setValue).toHaveBeenCalledWith(doc.toString()); + }); + }); + + describe('wrapCommentString', () => { + const longString = + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.'; + + it('should add spaces before each line', () => { + const result = YamlEditorExtension.wrapCommentString(longString); + const lines = result.split('\n'); + expect(lines.every((ln) => ln.startsWith(' '))).toBe(true); + }); + + it('should break long comments into lines of max. 79 chars', () => { + // 79 = 80 char width minus 1 char for the '#' at the start of each line + const result = YamlEditorExtension.wrapCommentString(longString); + const lines = result.split('\n'); + expect(lines.every((ln) => ln.length <= 79)).toBe(true); + }); + + it('should decrease the line width if passed a level by 2 chars per level', () => { + for (let i = 0; i <= 5; i += 1) { + const result = YamlEditorExtension.wrapCommentString(longString, i); + const lines = result.split('\n'); + const decreaseLineWidthBy = i * 2; + const maxLineWith = 79 - decreaseLineWidthBy; + const isValidLine = (ln) => { + if (ln.length <= maxLineWith) return true; + // The line may exceed the max line width in case the word is the + // only one in the line and thus cannot be broken further + return ln.split(' ').length <= 1; + }; + expect(lines.every(isValidLine)).toBe(true); + } + }); + + it('return null if passed an invalid string value', () => { + expect(YamlEditorExtension.wrapCommentString(null)).toBe(null); + expect(YamlEditorExtension.wrapCommentString()).toBe(null); + }); + + it('throw an error if passed an invalid level value', () => { + expect(() => YamlEditorExtension.wrapCommentString('abc', -5)).toThrow( + 'Invalid value "-5" for variable `level`', + ); + expect(() => YamlEditorExtension.wrapCommentString('abc', 'invalid')).toThrow( + 'Invalid value "invalid" for variable `level`', + ); + }); + }); + + describe('transformComments', () => { + const getInstanceWithModel = (model) => { + return getEditorInstanceWithExtension({ + model, + enableComments: true, + }); + }; + + it('converts comments inside an array', () => { + const model = ['# test comment', 'def', '# foo', 999]; + const expected = `# test comment\n- def\n# foo\n- 999\n`; + const instance = getInstanceWithModel(model); + expect(instance.getValue()).toEqual(expected); + }); + + it('converts generic comments inside an object and places them at the top', () => { + const model = { foo: 'bar', 1: 2, '#': 'test comment' }; + const expected = `# test comment\n"1": 2\nfoo: bar\n`; + const instance = getInstanceWithModel(model); + expect(instance.getValue()).toEqual(expected); + }); + + it('adds specific comments before the mentioned entry of an object', () => { + const model = { foo: 'bar', 1: 2, '#|foo': 'foo comment' }; + const expected = `"1": 2\n# foo comment\nfoo: bar\n`; + const instance = getInstanceWithModel(model); + expect(instance.getValue()).toEqual(expected); + }); + + it('limits long comments to 80 char width, including indentation', () => { + const model = { + '#|foo': + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.', + foo: { + nested1: { + nested2: { + nested3: { + '#|bar': + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.', + bar: 'baz', + }, + }, + }, + }, + }; + const expected = `# Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy +# eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam +# voluptua. At vero eos et accusam et justo duo dolores et ea rebum. +foo: + nested1: + nested2: + nested3: + # Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam + # nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + # sed diam voluptua. At vero eos et accusam et justo duo dolores et ea + # rebum. + bar: baz +`; + const instance = getInstanceWithModel(model); + expect(instance.getValue()).toEqual(expected); + }); + }); + + describe('getDoc', () => { + it('returns a yaml `Document` Type', () => { + const instance = getEditorInstanceWithExtension(); + expect(instance.getDoc()).toBeInstanceOf(Document); + }); + }); + + describe('setDoc', () => { + const model = { foo: 'bar', 1: 2, abc: ['def'] }; + const doc = new Document(model); + + it('should call transformComments if enableComments is true', () => { + const spy = jest.spyOn(YamlEditorExtension, 'transformComments'); + const instance = getEditorInstanceWithExtension({ enableComments: true }); + instance.setDoc(doc); + expect(spy).toHaveBeenCalledWith(doc); + }); + + it('should not call transformComments if enableComments is false', () => { + const spy = jest.spyOn(YamlEditorExtension, 'transformComments'); + const instance = getEditorInstanceWithExtension({ enableComments: false }); + instance.setDoc(doc); + expect(spy).not.toHaveBeenCalled(); + }); + + it("should call setValue with the stringified doc if the editor's value is empty", () => { + const instance = getEditorInstanceWithExtension(); + const setValue = jest.spyOn(instance, 'setValue'); + const updateValue = jest.spyOn(instance, 'updateValue'); + instance.setDoc(doc); + expect(setValue).toHaveBeenCalledWith(doc.toString()); + expect(updateValue).not.toHaveBeenCalled(); + }); + + it("should call updateValue with the stringified doc if the editor's value is not empty", () => { + const instance = getEditorInstanceWithExtension({}, { value: 'asjkdhkasjdh' }); + const setValue = jest.spyOn(instance, 'setValue'); + const updateValue = jest.spyOn(instance, 'updateValue'); + instance.setDoc(doc); + expect(setValue).not.toHaveBeenCalled(); + expect(updateValue).toHaveBeenCalledWith(doc.toString()); + }); + + it('should trigger the onUpdate method', () => { + const instance = getEditorInstanceWithExtension(); + const onUpdate = jest.spyOn(instance, 'onUpdate'); + instance.setDoc(doc); + expect(onUpdate).toHaveBeenCalled(); + }); + }); + + describe('getDataModel', () => { + it('returns the model as JS', () => { + const value = 'abc: def\nfoo:\n - bar\n - baz\n'; + const expected = { abc: 'def', foo: ['bar', 'baz'] }; + const instance = getEditorInstanceWithExtension({}, { value }); + expect(instance.getDataModel()).toEqual(expected); + }); + }); + + describe('setDataModel', () => { + it('sets the value to a YAML-representation of the Doc', () => { + const model = { + abc: ['def'], + '#|foo': 'foo comment', + foo: { + '#|abc': 'abc comment', + abc: [{ def: 'ghl', lorem: 'ipsum' }, '# array comment', null], + bar: 'baz', + }, + }; + const expected = + 'abc:\n' + + ' - def\n' + + '# foo comment\n' + + 'foo:\n' + + ' # abc comment\n' + + ' abc:\n' + + ' - def: ghl\n' + + ' lorem: ipsum\n' + + ' # array comment\n' + + ' - null\n' + + ' bar: baz\n'; + + const instance = getEditorInstanceWithExtension({ enableComments: true }); + const setValue = jest.spyOn(instance, 'setValue'); + + instance.setDataModel(model); + + expect(setValue).toHaveBeenCalledWith(expected); + }); + + it('causes the editor value to be updated', () => { + const initialModel = { foo: 'this should be overriden' }; + const initialValue = 'foo: this should be overriden\n'; + const newValue = { thisShould: 'be the actual value' }; + const expected = 'thisShould: be the actual value\n'; + const instance = getEditorInstanceWithExtension({ model: initialModel }); + expect(instance.getValue()).toEqual(initialValue); + instance.setDataModel(newValue); + expect(instance.getValue()).toEqual(expected); + }); + }); + + describe('onUpdate', () => { + it('calls highlight', () => { + const highlightPath = 'foo'; + const instance = getEditorInstanceWithExtension({ highlightPath }); + instance.highlight = jest.fn(); + instance.onUpdate(); + expect(instance.highlight).toHaveBeenCalledWith(highlightPath); + }); + }); + + describe('updateValue', () => { + it("causes the editor's value to be updated", () => { + const oldValue = 'foobar'; + const newValue = 'bazboo'; + const instance = getEditorInstanceWithExtension({}, { value: oldValue }); + instance.updateValue(newValue); + expect(instance.getValue()).toEqual(newValue); + }); + }); + + describe('highlight', () => { + const highlightPathOnSetup = 'abc'; + const value = `foo: + bar: + - baz + - boo + abc: def +`; + let instance; + let highlightLinesSpy; + let removeHighlightsSpy; + + beforeEach(() => { + instance = getEditorInstanceWithExtension({ highlightPath: highlightPathOnSetup }, { value }); + highlightLinesSpy = jest.spyOn(SourceEditorExtension, 'highlightLines'); + removeHighlightsSpy = jest.spyOn(SourceEditorExtension, 'removeHighlights'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('saves the highlighted path in highlightPath', () => { + const path = 'foo.bar'; + instance.highlight(path); + expect(instance.options.highlightPath).toEqual(path); + }); + + it('calls highlightLines with a number of lines', () => { + const path = 'foo.bar'; + instance.highlight(path); + expect(highlightLinesSpy).toHaveBeenCalledWith(instance, [2, 4]); + }); + + it('calls removeHighlights if path is null', () => { + instance.highlight(null); + expect(removeHighlightsSpy).toHaveBeenCalledWith(instance); + expect(highlightLinesSpy).not.toHaveBeenCalled(); + expect(instance.options.highlightPath).toBeNull(); + }); + + it('throws an error if path is invalid and does not change the highlighted path', () => { + expect(() => instance.highlight('invalidPath[0]')).toThrow( + 'The node invalidPath[0] could not be found inside the document.', + ); + expect(instance.options.highlightPath).toEqual(highlightPathOnSetup); + expect(highlightLinesSpy).not.toHaveBeenCalled(); + expect(removeHighlightsSpy).not.toHaveBeenCalled(); + }); + }); + + describe('locate', () => { + const options = { + enableComments: true, + model: { + abc: ['def'], + '#|foo': 'foo comment', + foo: { + '#|abc': 'abc comment', + abc: [{ def: 'ghl', lorem: 'ipsum' }, '# array comment', null], + bar: 'baz', + }, + }, + }; + + const value = + /* 1 */ 'abc:\n' + + /* 2 */ ' - def\n' + + /* 3 */ '# foo comment\n' + + /* 4 */ 'foo:\n' + + /* 5 */ ' # abc comment\n' + + /* 6 */ ' abc:\n' + + /* 7 */ ' - def: ghl\n' + + /* 8 */ ' lorem: ipsum\n' + + /* 9 */ ' # array comment\n' + + /* 10 */ ' - null\n' + + /* 11 */ ' bar: baz\n'; + + it('asserts that the test setup is correct', () => { + const instance = getEditorInstanceWithExtension(options); + expect(instance.getValue()).toEqual(value); + }); + + it('returns the expected line numbers for a path to an object inside the yaml', () => { + const path = 'foo.abc'; + const expected = [6, 10]; + const instance = getEditorInstanceWithExtension(options); + expect(instance.locate(path)).toEqual(expected); + }); + + it('throws an error if a path cannot be found inside the yaml', () => { + const path = 'baz[8]'; + const instance = getEditorInstanceWithExtension(options); + expect(() => instance.locate(path)).toThrow(); + }); + + it('returns the expected line numbers for a path to an array entry inside the yaml', () => { + const path = 'foo.abc[0]'; + const expected = [7, 8]; + const instance = getEditorInstanceWithExtension(options); + expect(instance.locate(path)).toEqual(expected); + }); + + it('returns the expected line numbers for a path that includes a comment inside the yaml', () => { + const path = 'foo'; + const expected = [4, 11]; + const instance = getEditorInstanceWithExtension(options); + expect(instance.locate(path)).toEqual(expected); + }); + }); +}); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js new file mode 100644 index 00000000000..e56b6448b7d --- /dev/null +++ b/spec/frontend/environments/graphql/mock_data.js @@ -0,0 +1,530 @@ +export const environmentsApp = { + environments: [ + { + name: 'review', + size: 2, + latest: { + id: 42, + global_id: 'gid://gitlab/Environment/42', + name: 'review/goodbye', + state: 'available', + external_url: 'https://example.org', + environment_type: 'review', + name_without_type: 'goodbye', + last_deployment: null, + has_stop_action: false, + rollout_status: null, + environment_path: '/h5bp/html5-boilerplate/-/environments/42', + stop_path: '/h5bp/html5-boilerplate/-/environments/42/stop', + cancel_auto_stop_path: '/h5bp/html5-boilerplate/-/environments/42/cancel_auto_stop', + delete_path: '/api/v4/projects/8/environments/42', + folder_path: '/h5bp/html5-boilerplate/-/environments/folders/review', + created_at: '2021-10-04T19:27:20.639Z', + updated_at: '2021-10-04T19:27:20.639Z', + can_stop: true, + logs_path: '/h5bp/html5-boilerplate/-/logs?environment_name=review%2Fgoodbye', + logs_api_path: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=review%2Fgoodbye', + enable_advanced_logs_querying: false, + can_delete: false, + has_opened_alert: false, + }, + }, + { + name: 'production', + size: 1, + latest: { + id: 8, + global_id: 'gid://gitlab/Environment/8', + name: 'production', + state: 'available', + external_url: 'https://example.org', + environment_type: null, + name_without_type: 'production', + last_deployment: { + id: 80, + iid: 24, + sha: '4ca0310329e8f251b892d7be205eec8b7dd220e5', + ref: { + name: 'root-master-patch-18104', + ref_path: '/h5bp/html5-boilerplate/-/tree/root-master-patch-18104', + }, + status: 'success', + created_at: '2021-10-08T19:53:54.543Z', + deployed_at: '2021-10-08T20:02:36.763Z', + tag: false, + 'last?': true, + user: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + deployable: { + id: 911, + name: 'deploy-job', + started: '2021-10-08T19:54:00.658Z', + complete: true, + archived: false, + build_path: '/h5bp/html5-boilerplate/-/jobs/911', + retry_path: '/h5bp/html5-boilerplate/-/jobs/911/retry', + play_path: '/h5bp/html5-boilerplate/-/jobs/911/play', + playable: true, + scheduled: false, + created_at: '2021-10-08T19:53:54.482Z', + updated_at: '2021-10-08T20:02:36.730Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'manual play action', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/h5bp/html5-boilerplate/-/jobs/911', + illustration: { + image: + '/assets/illustrations/manual_action-c55aee2c5f9ebe9f72751480af8bb307be1a6f35552f344cc6d1bf979d3422f6.svg', + size: 'svg-394', + title: 'This job requires a manual action', + content: + 'This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'play', + title: 'Play', + path: '/h5bp/html5-boilerplate/-/jobs/911/play', + method: 'post', + button_title: 'Trigger this manual action', + }, + }, + }, + commit: { + id: '4ca0310329e8f251b892d7be205eec8b7dd220e5', + short_id: '4ca03103', + created_at: '2021-10-08T19:27:01.000+00:00', + parent_ids: ['b385360b15bd61391a0efbd101788d4a80387270'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2021-10-08T19:27:01.000+00:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2021-10-08T19:27:01.000+00:00', + trailers: {}, + web_url: + 'http://gdk.test:3000/h5bp/html5-boilerplate/-/commit/4ca0310329e8f251b892d7be205eec8b7dd220e5', + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commit_url: + 'http://gdk.test:3000/h5bp/html5-boilerplate/-/commit/4ca0310329e8f251b892d7be205eec8b7dd220e5', + commit_path: + '/h5bp/html5-boilerplate/-/commit/4ca0310329e8f251b892d7be205eec8b7dd220e5', + }, + manual_actions: [], + scheduled_actions: [], + playable_build: { + retry_path: '/h5bp/html5-boilerplate/-/jobs/911/retry', + play_path: '/h5bp/html5-boilerplate/-/jobs/911/play', + }, + cluster: null, + }, + has_stop_action: false, + rollout_status: null, + environment_path: '/h5bp/html5-boilerplate/-/environments/8', + stop_path: '/h5bp/html5-boilerplate/-/environments/8/stop', + cancel_auto_stop_path: '/h5bp/html5-boilerplate/-/environments/8/cancel_auto_stop', + delete_path: '/api/v4/projects/8/environments/8', + folder_path: '/h5bp/html5-boilerplate/-/environments/folders/production', + created_at: '2021-06-17T15:09:38.599Z', + updated_at: '2021-10-08T19:50:44.445Z', + can_stop: true, + logs_path: '/h5bp/html5-boilerplate/-/logs?environment_name=production', + logs_api_path: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=production', + enable_advanced_logs_querying: false, + can_delete: false, + has_opened_alert: false, + }, + }, + { + name: 'staging', + size: 1, + latest: { + id: 7, + global_id: 'gid://gitlab/Environment/7', + name: 'staging', + state: 'available', + external_url: null, + environment_type: null, + name_without_type: 'staging', + last_deployment: null, + has_stop_action: false, + rollout_status: null, + environment_path: '/h5bp/html5-boilerplate/-/environments/7', + stop_path: '/h5bp/html5-boilerplate/-/environments/7/stop', + cancel_auto_stop_path: '/h5bp/html5-boilerplate/-/environments/7/cancel_auto_stop', + delete_path: '/api/v4/projects/8/environments/7', + folder_path: '/h5bp/html5-boilerplate/-/environments/folders/staging', + created_at: '2021-06-17T15:09:38.570Z', + updated_at: '2021-06-17T15:09:38.570Z', + can_stop: true, + logs_path: '/h5bp/html5-boilerplate/-/logs?environment_name=staging', + logs_api_path: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=staging', + enable_advanced_logs_querying: false, + can_delete: false, + has_opened_alert: false, + }, + }, + ], + review_app: { + can_setup_review_app: true, + all_clusters_empty: true, + review_snippet: + '{"deploy_review"=>{"stage"=>"deploy", "script"=>["echo \\"Deploy a review app\\""], "environment"=>{"name"=>"review/$CI_COMMIT_REF_NAME", "url"=>"https://$CI_ENVIRONMENT_SLUG.example.com"}, "only"=>["branches"]}}', + }, + available_count: 4, + stopped_count: 0, +}; + +export const resolvedEnvironmentsApp = { + availableCount: 4, + environments: [ + { + name: 'review', + size: 2, + latest: { + id: 42, + globalId: 'gid://gitlab/Environment/42', + name: 'review/goodbye', + state: 'available', + externalUrl: 'https://example.org', + environmentType: 'review', + nameWithoutType: 'goodbye', + lastDeployment: null, + hasStopAction: false, + rolloutStatus: null, + environmentPath: '/h5bp/html5-boilerplate/-/environments/42', + stopPath: '/h5bp/html5-boilerplate/-/environments/42/stop', + cancelAutoStopPath: '/h5bp/html5-boilerplate/-/environments/42/cancel_auto_stop', + deletePath: '/api/v4/projects/8/environments/42', + folderPath: '/h5bp/html5-boilerplate/-/environments/folders/review', + createdAt: '2021-10-04T19:27:20.639Z', + updatedAt: '2021-10-04T19:27:20.639Z', + canStop: true, + logsPath: '/h5bp/html5-boilerplate/-/logs?environment_name=review%2Fgoodbye', + logsApiPath: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=review%2Fgoodbye', + enableAdvancedLogsQuerying: false, + canDelete: false, + hasOpenedAlert: false, + }, + __typename: 'NestedLocalEnvironment', + }, + { + name: 'production', + size: 1, + latest: { + id: 8, + globalId: 'gid://gitlab/Environment/8', + name: 'production', + state: 'available', + externalUrl: 'https://example.org', + environmentType: null, + nameWithoutType: 'production', + lastDeployment: { + id: 80, + iid: 24, + sha: '4ca0310329e8f251b892d7be205eec8b7dd220e5', + ref: { + name: 'root-master-patch-18104', + refPath: '/h5bp/html5-boilerplate/-/tree/root-master-patch-18104', + }, + status: 'success', + createdAt: '2021-10-08T19:53:54.543Z', + deployedAt: '2021-10-08T20:02:36.763Z', + tag: false, + 'last?': true, + user: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webUrl: 'http://gdk.test:3000/root', + showStatus: false, + path: '/root', + }, + deployable: { + id: 911, + name: 'deploy-job', + started: '2021-10-08T19:54:00.658Z', + complete: true, + archived: false, + buildPath: '/h5bp/html5-boilerplate/-/jobs/911', + retryPath: '/h5bp/html5-boilerplate/-/jobs/911/retry', + playPath: '/h5bp/html5-boilerplate/-/jobs/911/play', + playable: true, + scheduled: false, + createdAt: '2021-10-08T19:53:54.482Z', + updatedAt: '2021-10-08T20:02:36.730Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'manual play action', + group: 'success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/h5bp/html5-boilerplate/-/jobs/911', + illustration: { + image: + '/assets/illustrations/manual_action-c55aee2c5f9ebe9f72751480af8bb307be1a6f35552f344cc6d1bf979d3422f6.svg', + size: 'svg-394', + title: 'This job requires a manual action', + content: + 'This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'play', + title: 'Play', + path: '/h5bp/html5-boilerplate/-/jobs/911/play', + method: 'post', + buttonTitle: 'Trigger this manual action', + }, + }, + }, + commit: { + id: '4ca0310329e8f251b892d7be205eec8b7dd220e5', + shortId: '4ca03103', + createdAt: '2021-10-08T19:27:01.000+00:00', + parentIds: ['b385360b15bd61391a0efbd101788d4a80387270'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + authorName: 'Administrator', + authorEmail: 'admin@example.com', + authoredDate: '2021-10-08T19:27:01.000+00:00', + committerName: 'Administrator', + committerEmail: 'admin@example.com', + committedDate: '2021-10-08T19:27:01.000+00:00', + trailers: {}, + webUrl: + 'http://gdk.test:3000/h5bp/html5-boilerplate/-/commit/4ca0310329e8f251b892d7be205eec8b7dd220e5', + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webUrl: 'http://gdk.test:3000/root', + showStatus: false, + path: '/root', + }, + authorGravatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commitUrl: + 'http://gdk.test:3000/h5bp/html5-boilerplate/-/commit/4ca0310329e8f251b892d7be205eec8b7dd220e5', + commitPath: '/h5bp/html5-boilerplate/-/commit/4ca0310329e8f251b892d7be205eec8b7dd220e5', + }, + manualActions: [], + scheduledActions: [], + playableBuild: { + retryPath: '/h5bp/html5-boilerplate/-/jobs/911/retry', + playPath: '/h5bp/html5-boilerplate/-/jobs/911/play', + }, + cluster: null, + }, + hasStopAction: false, + rolloutStatus: null, + environmentPath: '/h5bp/html5-boilerplate/-/environments/8', + stopPath: '/h5bp/html5-boilerplate/-/environments/8/stop', + cancelAutoStopPath: '/h5bp/html5-boilerplate/-/environments/8/cancel_auto_stop', + deletePath: '/api/v4/projects/8/environments/8', + folderPath: '/h5bp/html5-boilerplate/-/environments/folders/production', + createdAt: '2021-06-17T15:09:38.599Z', + updatedAt: '2021-10-08T19:50:44.445Z', + canStop: true, + logsPath: '/h5bp/html5-boilerplate/-/logs?environment_name=production', + logsApiPath: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=production', + enableAdvancedLogsQuerying: false, + canDelete: false, + hasOpenedAlert: false, + }, + __typename: 'NestedLocalEnvironment', + }, + { + name: 'staging', + size: 1, + latest: { + id: 7, + globalId: 'gid://gitlab/Environment/7', + name: 'staging', + state: 'available', + externalUrl: null, + environmentType: null, + nameWithoutType: 'staging', + lastDeployment: null, + hasStopAction: false, + rolloutStatus: null, + environmentPath: '/h5bp/html5-boilerplate/-/environments/7', + stopPath: '/h5bp/html5-boilerplate/-/environments/7/stop', + cancelAutoStopPath: '/h5bp/html5-boilerplate/-/environments/7/cancel_auto_stop', + deletePath: '/api/v4/projects/8/environments/7', + folderPath: '/h5bp/html5-boilerplate/-/environments/folders/staging', + createdAt: '2021-06-17T15:09:38.570Z', + updatedAt: '2021-06-17T15:09:38.570Z', + canStop: true, + logsPath: '/h5bp/html5-boilerplate/-/logs?environment_name=staging', + logsApiPath: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=staging', + enableAdvancedLogsQuerying: false, + canDelete: false, + hasOpenedAlert: false, + }, + __typename: 'NestedLocalEnvironment', + }, + ], + reviewApp: { + canSetupReviewApp: true, + allClustersEmpty: true, + reviewSnippet: + '{"deploy_review"=>{"stage"=>"deploy", "script"=>["echo \\"Deploy a review app\\""], "environment"=>{"name"=>"review/$CI_COMMIT_REF_NAME", "url"=>"https://$CI_ENVIRONMENT_SLUG.example.com"}, "only"=>["branches"]}}', + __typename: 'ReviewApp', + }, + stoppedCount: 0, + __typename: 'LocalEnvironmentApp', +}; + +export const folder = { + environments: [ + { + id: 42, + global_id: 'gid://gitlab/Environment/42', + name: 'review/goodbye', + state: 'available', + external_url: 'https://example.org', + environment_type: 'review', + name_without_type: 'goodbye', + last_deployment: null, + has_stop_action: false, + rollout_status: null, + environment_path: '/h5bp/html5-boilerplate/-/environments/42', + stop_path: '/h5bp/html5-boilerplate/-/environments/42/stop', + cancel_auto_stop_path: '/h5bp/html5-boilerplate/-/environments/42/cancel_auto_stop', + delete_path: '/api/v4/projects/8/environments/42', + folder_path: '/h5bp/html5-boilerplate/-/environments/folders/review', + created_at: '2021-10-04T19:27:20.639Z', + updated_at: '2021-10-04T19:27:20.639Z', + can_stop: true, + logs_path: '/h5bp/html5-boilerplate/-/logs?environment_name=review%2Fgoodbye', + logs_api_path: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=review%2Fgoodbye', + enable_advanced_logs_querying: false, + can_delete: false, + has_opened_alert: false, + }, + { + id: 41, + global_id: 'gid://gitlab/Environment/41', + name: 'review/hello', + state: 'available', + external_url: 'https://example.org', + environment_type: 'review', + name_without_type: 'hello', + last_deployment: null, + has_stop_action: false, + rollout_status: null, + environment_path: '/h5bp/html5-boilerplate/-/environments/41', + stop_path: '/h5bp/html5-boilerplate/-/environments/41/stop', + cancel_auto_stop_path: '/h5bp/html5-boilerplate/-/environments/41/cancel_auto_stop', + delete_path: '/api/v4/projects/8/environments/41', + folder_path: '/h5bp/html5-boilerplate/-/environments/folders/review', + created_at: '2021-10-04T19:27:00.527Z', + updated_at: '2021-10-04T19:27:00.527Z', + can_stop: true, + logs_path: '/h5bp/html5-boilerplate/-/logs?environment_name=review%2Fhello', + logs_api_path: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=review%2Fhello', + enable_advanced_logs_querying: false, + can_delete: false, + has_opened_alert: false, + }, + ], + available_count: 2, + stopped_count: 0, +}; + +export const resolvedFolder = { + availableCount: 2, + environments: [ + { + id: 42, + globalId: 'gid://gitlab/Environment/42', + name: 'review/goodbye', + state: 'available', + externalUrl: 'https://example.org', + environmentType: 'review', + nameWithoutType: 'goodbye', + lastDeployment: null, + hasStopAction: false, + rolloutStatus: null, + environmentPath: '/h5bp/html5-boilerplate/-/environments/42', + stopPath: '/h5bp/html5-boilerplate/-/environments/42/stop', + cancelAutoStopPath: '/h5bp/html5-boilerplate/-/environments/42/cancel_auto_stop', + deletePath: '/api/v4/projects/8/environments/42', + folderPath: '/h5bp/html5-boilerplate/-/environments/folders/review', + createdAt: '2021-10-04T19:27:20.639Z', + updatedAt: '2021-10-04T19:27:20.639Z', + canStop: true, + logsPath: '/h5bp/html5-boilerplate/-/logs?environment_name=review%2Fgoodbye', + logsApiPath: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=review%2Fgoodbye', + enableAdvancedLogsQuerying: false, + canDelete: false, + hasOpenedAlert: false, + __typename: 'LocalEnvironment', + }, + { + id: 41, + globalId: 'gid://gitlab/Environment/41', + name: 'review/hello', + state: 'available', + externalUrl: 'https://example.org', + environmentType: 'review', + nameWithoutType: 'hello', + lastDeployment: null, + hasStopAction: false, + rolloutStatus: null, + environmentPath: '/h5bp/html5-boilerplate/-/environments/41', + stopPath: '/h5bp/html5-boilerplate/-/environments/41/stop', + cancelAutoStopPath: '/h5bp/html5-boilerplate/-/environments/41/cancel_auto_stop', + deletePath: '/api/v4/projects/8/environments/41', + folderPath: '/h5bp/html5-boilerplate/-/environments/folders/review', + createdAt: '2021-10-04T19:27:00.527Z', + updatedAt: '2021-10-04T19:27:00.527Z', + canStop: true, + logsPath: '/h5bp/html5-boilerplate/-/logs?environment_name=review%2Fhello', + logsApiPath: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=review%2Fhello', + enableAdvancedLogsQuerying: false, + canDelete: false, + hasOpenedAlert: false, + __typename: 'LocalEnvironment', + }, + ], + stoppedCount: 0, + __typename: 'LocalEnvironmentFolder', +}; diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js new file mode 100644 index 00000000000..4d2a0818996 --- /dev/null +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -0,0 +1,91 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { resolvers } from '~/environments/graphql/resolvers'; +import { TEST_HOST } from 'helpers/test_constants'; +import { environmentsApp, resolvedEnvironmentsApp, folder, resolvedFolder } from './mock_data'; + +const ENDPOINT = `${TEST_HOST}/environments`; + +describe('~/frontend/environments/graphql/resolvers', () => { + let mockResolvers; + let mock; + + beforeEach(() => { + mockResolvers = resolvers(ENDPOINT); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + describe('environmentApp', () => { + it('should fetch environments and map them to frontend data', async () => { + mock.onGet(ENDPOINT, { params: { nested: true } }).reply(200, environmentsApp); + + const app = await mockResolvers.Query.environmentApp(); + expect(app).toEqual(resolvedEnvironmentsApp); + }); + }); + describe('folder', () => { + it('should fetch the folder url passed to it', async () => { + mock.onGet(ENDPOINT, { params: { per_page: 3 } }).reply(200, folder); + + const environmentFolder = await mockResolvers.Query.folder(null, { + environment: { folderPath: ENDPOINT }, + }); + + expect(environmentFolder).toEqual(resolvedFolder); + }); + }); + describe('stopEnvironment', () => { + it('should post to the stop environment path', async () => { + mock.onPost(ENDPOINT).reply(200); + + await mockResolvers.Mutations.stopEnvironment(null, { environment: { stopPath: ENDPOINT } }); + + expect(mock.history.post).toContainEqual( + expect.objectContaining({ url: ENDPOINT, method: 'post' }), + ); + }); + }); + describe('rollbackEnvironment', () => { + it('should post to the retry environment path', async () => { + mock.onPost(ENDPOINT).reply(200); + + await mockResolvers.Mutations.rollbackEnvironment(null, { + environment: { retryUrl: ENDPOINT }, + }); + + expect(mock.history.post).toContainEqual( + expect.objectContaining({ url: ENDPOINT, method: 'post' }), + ); + }); + }); + describe('deleteEnvironment', () => { + it('should DELETE to the delete environment path', async () => { + mock.onDelete(ENDPOINT).reply(200); + + await mockResolvers.Mutations.deleteEnvironment(null, { + environment: { deletePath: ENDPOINT }, + }); + + expect(mock.history.delete).toContainEqual( + expect.objectContaining({ url: ENDPOINT, method: 'delete' }), + ); + }); + }); + describe('cancelAutoStop', () => { + it('should post to the auto stop path', async () => { + mock.onPost(ENDPOINT).reply(200); + + await mockResolvers.Mutations.cancelAutoStop(null, { + environment: { autoStopPath: ENDPOINT }, + }); + + expect(mock.history.post).toContainEqual( + expect.objectContaining({ url: ENDPOINT, method: 'post' }), + ); + }); + }); +}); diff --git a/spec/frontend/environments/new_environment_folder_spec.js b/spec/frontend/environments/new_environment_folder_spec.js new file mode 100644 index 00000000000..5696e187a86 --- /dev/null +++ b/spec/frontend/environments/new_environment_folder_spec.js @@ -0,0 +1,74 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { GlCollapse, GlIcon } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; +import { s__ } from '~/locale'; +import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/new_environments_folder.vue', () => { + let wrapper; + let environmentFolderMock; + let nestedEnvironment; + let folderName; + + const findLink = () => wrapper.findByRole('link', { name: s__('Environments|Show all') }); + + const createApolloProvider = () => { + const mockResolvers = { Query: { folder: environmentFolderMock } }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (propsData, apolloProvider) => + mountExtended(EnvironmentsFolder, { apolloProvider, propsData }); + + beforeEach(() => { + environmentFolderMock = jest.fn(); + [nestedEnvironment] = resolvedEnvironmentsApp.environments; + environmentFolderMock.mockReturnValue(resolvedFolder); + wrapper = createWrapper({ nestedEnvironment }, createApolloProvider()); + folderName = wrapper.findByText(nestedEnvironment.name); + }); + + afterEach(() => { + wrapper?.destroy(); + }); + + it('displays the name of the folder', () => { + expect(folderName.text()).toBe(nestedEnvironment.name); + }); + + describe('collapse', () => { + let icons; + let collapse; + + beforeEach(() => { + collapse = wrapper.findComponent(GlCollapse); + icons = wrapper.findAllComponents(GlIcon); + }); + + it('is collapsed by default', () => { + const link = findLink(); + + expect(collapse.attributes('visible')).toBeUndefined(); + expect(icons.wrappers.map((i) => i.props('name'))).toEqual(['angle-right', 'folder-o']); + expect(folderName.classes('gl-font-weight-bold')).toBe(false); + expect(link.exists()).toBe(false); + }); + + it('opens on click', async () => { + await folderName.trigger('click'); + + const link = findLink(); + + expect(collapse.attributes('visible')).toBe('true'); + expect(icons.wrappers.map((i) => i.props('name'))).toEqual(['angle-down', 'folder-open']); + expect(folderName.classes('gl-font-weight-bold')).toBe(true); + expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath); + }); + }); +}); diff --git a/spec/frontend/environments/new_environments_app_spec.js b/spec/frontend/environments/new_environments_app_spec.js new file mode 100644 index 00000000000..0ad8e8f442c --- /dev/null +++ b/spec/frontend/environments/new_environments_app_spec.js @@ -0,0 +1,50 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import EnvironmentsApp from '~/environments/components/new_environments_app.vue'; +import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; +import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/new_environments_app.vue', () => { + let wrapper; + let environmentAppMock; + let environmentFolderMock; + + const createApolloProvider = () => { + const mockResolvers = { + Query: { environmentApp: environmentAppMock, folder: environmentFolderMock }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider) => mount(EnvironmentsApp, { apolloProvider }); + + beforeEach(() => { + environmentAppMock = jest.fn(); + environmentFolderMock = jest.fn(); + }); + + afterEach(() => { + wrapper?.destroy(); + }); + + it('should show all the folders that are fetched', async () => { + environmentAppMock.mockReturnValue(resolvedEnvironmentsApp); + environmentFolderMock.mockReturnValue(resolvedFolder); + const apolloProvider = createApolloProvider(); + wrapper = createWrapper(apolloProvider); + + await waitForPromises(); + await Vue.nextTick(); + + const text = wrapper.findAllComponents(EnvironmentsFolder).wrappers.map((w) => w.text()); + + expect(text).toContainEqual(expect.stringMatching('review')); + expect(text).not.toContainEqual(expect.stringMatching('production')); + }); +}); diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js index de060f5eb8c..923795ca3f3 100644 --- a/spec/frontend/experimentation/utils_spec.js +++ b/spec/frontend/experimentation/utils_spec.js @@ -1,4 +1,4 @@ -import { assignGitlabExperiment } from 'helpers/experimentation_helper'; +import { stubExperiments } from 'helpers/experimentation_helper'; import { DEFAULT_VARIANT, CANDIDATE_VARIANT, @@ -7,15 +7,45 @@ import { import * as experimentUtils from '~/experimentation/utils'; describe('experiment Utilities', () => { - const TEST_KEY = 'abc'; + const ABC_KEY = 'abc'; + const DEF_KEY = 'def'; + + let origGon; + let origGl; + + beforeEach(() => { + origGon = window.gon; + origGl = window.gl; + window.gon.experiment = {}; + window.gl.experiments = {}; + }); + + afterEach(() => { + window.gon = origGon; + window.gl = origGl; + }); describe('getExperimentData', () => { + const ABC_DATA = '_abc_data_'; + const ABC_DATA2 = '_updated_abc_data_'; + const DEF_DATA = '_def_data_'; + describe.each` - gon | input | output - ${[TEST_KEY, '_data_']} | ${[TEST_KEY]} | ${{ variant: '_data_' }} - ${[]} | ${[TEST_KEY]} | ${undefined} - `('with input=$input and gon=$gon', ({ gon, input, output }) => { - assignGitlabExperiment(...gon); + gonData | glData | input | output + ${[ABC_KEY, ABC_DATA]} | ${[]} | ${[ABC_KEY]} | ${{ experiment: ABC_KEY, variant: ABC_DATA }} + ${[]} | ${[ABC_KEY, ABC_DATA]} | ${[ABC_KEY]} | ${{ experiment: ABC_KEY, variant: ABC_DATA }} + ${[ABC_KEY, ABC_DATA]} | ${[DEF_KEY, DEF_DATA]} | ${[ABC_KEY]} | ${{ experiment: ABC_KEY, variant: ABC_DATA }} + ${[ABC_KEY, ABC_DATA]} | ${[DEF_KEY, DEF_DATA]} | ${[DEF_KEY]} | ${{ experiment: DEF_KEY, variant: DEF_DATA }} + ${[ABC_KEY, ABC_DATA]} | ${[ABC_KEY, ABC_DATA2]} | ${[ABC_KEY]} | ${{ experiment: ABC_KEY, variant: ABC_DATA2 }} + ${[]} | ${[]} | ${[ABC_KEY]} | ${undefined} + `('with input=$input, gon=$gonData, & gl=$glData', ({ gonData, glData, input, output }) => { + beforeEach(() => { + const [gonKey, gonVariant] = gonData; + const [glKey, glVariant] = glData; + + if (gonKey) window.gon.experiment[gonKey] = { experiment: gonKey, variant: gonVariant }; + if (glKey) window.gl.experiments[glKey] = { experiment: glKey, variant: glVariant }; + }); it(`returns ${output}`, () => { expect(experimentUtils.getExperimentData(...input)).toEqual(output); @@ -25,106 +55,129 @@ describe('experiment Utilities', () => { describe('getAllExperimentContexts', () => { const schema = TRACKING_CONTEXT_SCHEMA; - let origGon; - - beforeEach(() => { - origGon = window.gon; - }); - - afterEach(() => { - window.gon = origGon; - }); it('collects all of the experiment contexts into a single array', () => { - const experiments = [ - { experiment: 'abc', variant: 'candidate' }, - { experiment: 'def', variant: 'control' }, - { experiment: 'ghi', variant: 'blue' }, - ]; - window.gon = { - experiment: experiments.reduce((collector, { experiment, variant }) => { - return { ...collector, [experiment]: { experiment, variant } }; - }, {}), - }; + const experiments = { [ABC_KEY]: 'candidate', [DEF_KEY]: 'control', ghi: 'blue' }; + + stubExperiments(experiments); expect(experimentUtils.getAllExperimentContexts()).toEqual( - experiments.map((data) => ({ schema, data })), + Object.entries(experiments).map(([experiment, variant]) => ({ + schema, + data: { experiment, variant }, + })), ); }); it('returns an empty array if there are no experiments', () => { - window.gon.experiment = {}; - expect(experimentUtils.getAllExperimentContexts()).toEqual([]); }); - it('includes all additional experiment data', () => { - const experiment = 'experimentWithCustomData'; - const data = { experiment, variant: 'control', color: 'blue', style: 'rounded' }; - window.gon.experiment[experiment] = data; + it('only collects the data properties which are supported by the schema', () => { + origGl = window.gl; + window.gl.experiments = { + my_experiment: { experiment: 'my_experiment', variant: 'control', excluded: false }, + }; + + expect(experimentUtils.getAllExperimentContexts()).toEqual([ + { schema, data: { experiment: 'my_experiment', variant: 'control' } }, + ]); - expect(experimentUtils.getAllExperimentContexts()).toContainEqual({ schema, data }); + window.gl = origGl; }); }); describe('isExperimentVariant', () => { describe.each` - gon | input | output - ${[TEST_KEY, DEFAULT_VARIANT]} | ${[TEST_KEY, DEFAULT_VARIANT]} | ${true} - ${[TEST_KEY, '_variant_name']} | ${[TEST_KEY, '_variant_name']} | ${true} - ${[TEST_KEY, '_variant_name']} | ${[TEST_KEY, '_bogus_name']} | ${false} - ${[TEST_KEY, '_variant_name']} | ${['boguskey', '_variant_name']} | ${false} - ${[]} | ${[TEST_KEY, '_variant_name']} | ${false} - `('with input=$input and gon=$gon', ({ gon, input, output }) => { - assignGitlabExperiment(...gon); - - it(`returns ${output}`, () => { - expect(experimentUtils.isExperimentVariant(...input)).toEqual(output); - }); - }); + experiment | variant | input | output + ${ABC_KEY} | ${DEFAULT_VARIANT} | ${[ABC_KEY, DEFAULT_VARIANT]} | ${true} + ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_variant_name']} | ${true} + ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_bogus_name']} | ${false} + ${ABC_KEY} | ${'_variant_name'} | ${['boguskey', '_variant_name']} | ${false} + ${undefined} | ${undefined} | ${[ABC_KEY, '_variant_name']} | ${false} + `( + 'with input=$input, experiment=$experiment, variant=$variant', + ({ experiment, variant, input, output }) => { + it(`returns ${output}`, () => { + if (experiment) stubExperiments({ [experiment]: variant }); + + expect(experimentUtils.isExperimentVariant(...input)).toEqual(output); + }); + }, + ); }); describe('experiment', () => { + const experiment = 'marley'; + const useSpy = jest.fn(); const controlSpy = jest.fn(); + const trySpy = jest.fn(); const candidateSpy = jest.fn(); const getUpStandUpSpy = jest.fn(); const variants = { - use: controlSpy, - try: candidateSpy, + use: useSpy, + try: trySpy, get_up_stand_up: getUpStandUpSpy, }; describe('when there is no experiment data', () => { - it('calls control variant', () => { - experimentUtils.experiment('marley', variants); - expect(controlSpy).toHaveBeenCalled(); + it('calls the use variant', () => { + experimentUtils.experiment(experiment, variants); + expect(useSpy).toHaveBeenCalled(); + }); + + describe("when 'control' is provided instead of 'use'", () => { + it('calls the control variant', () => { + experimentUtils.experiment(experiment, { control: controlSpy }); + expect(controlSpy).toHaveBeenCalled(); + }); }); }); describe('when experiment variant is "control"', () => { - assignGitlabExperiment('marley', DEFAULT_VARIANT); + beforeEach(() => { + stubExperiments({ [experiment]: DEFAULT_VARIANT }); + }); - it('calls the control variant', () => { - experimentUtils.experiment('marley', variants); - expect(controlSpy).toHaveBeenCalled(); + it('calls the use variant', () => { + experimentUtils.experiment(experiment, variants); + expect(useSpy).toHaveBeenCalled(); + }); + + describe("when 'control' is provided instead of 'use'", () => { + it('calls the control variant', () => { + experimentUtils.experiment(experiment, { control: controlSpy }); + expect(controlSpy).toHaveBeenCalled(); + }); }); }); describe('when experiment variant is "candidate"', () => { - assignGitlabExperiment('marley', CANDIDATE_VARIANT); + beforeEach(() => { + stubExperiments({ [experiment]: CANDIDATE_VARIANT }); + }); - it('calls the candidate variant', () => { - experimentUtils.experiment('marley', variants); - expect(candidateSpy).toHaveBeenCalled(); + it('calls the try variant', () => { + experimentUtils.experiment(experiment, variants); + expect(trySpy).toHaveBeenCalled(); + }); + + describe("when 'candidate' is provided instead of 'try'", () => { + it('calls the candidate variant', () => { + experimentUtils.experiment(experiment, { candidate: candidateSpy }); + expect(candidateSpy).toHaveBeenCalled(); + }); }); }); describe('when experiment variant is "get_up_stand_up"', () => { - assignGitlabExperiment('marley', 'get_up_stand_up'); + beforeEach(() => { + stubExperiments({ [experiment]: 'get_up_stand_up' }); + }); it('calls the get-up-stand-up variant', () => { - experimentUtils.experiment('marley', variants); + experimentUtils.experiment(experiment, variants); expect(getUpStandUpSpy).toHaveBeenCalled(); }); }); @@ -132,14 +185,17 @@ describe('experiment Utilities', () => { describe('getExperimentVariant', () => { it.each` - gon | input | output - ${{ experiment: { [TEST_KEY]: { variant: DEFAULT_VARIANT } } }} | ${[TEST_KEY]} | ${DEFAULT_VARIANT} - ${{ experiment: { [TEST_KEY]: { variant: CANDIDATE_VARIANT } } }} | ${[TEST_KEY]} | ${CANDIDATE_VARIANT} - ${{}} | ${[TEST_KEY]} | ${DEFAULT_VARIANT} - `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => { - window.gon = gon; - - expect(experimentUtils.getExperimentVariant(...input)).toEqual(output); - }); + experiment | variant | input | output + ${ABC_KEY} | ${DEFAULT_VARIANT} | ${ABC_KEY} | ${DEFAULT_VARIANT} + ${ABC_KEY} | ${CANDIDATE_VARIANT} | ${ABC_KEY} | ${CANDIDATE_VARIANT} + ${undefined} | ${undefined} | ${ABC_KEY} | ${DEFAULT_VARIANT} + `( + 'with input=$input, experiment=$experiment, & variant=$variant; returns $output', + ({ experiment, variant, input, output }) => { + stubExperiments({ [experiment]: variant }); + + expect(experimentUtils.getExperimentVariant(input)).toEqual(output); + }, + ); }); }); diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js index 27ec6a7280f..f244da228b3 100644 --- a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js +++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js @@ -1,5 +1,6 @@ import { GlModal, GlSprintf, GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Component from '~/feature_flags/components/configure_feature_flags_modal.vue'; describe('Configure Feature Flags Modal', () => { @@ -20,7 +21,7 @@ describe('Configure Feature Flags Modal', () => { }; let wrapper; - const factory = (props = {}, { mountFn = shallowMount, ...options } = {}) => { + const factory = (props = {}, { mountFn = shallowMountExtended, ...options } = {}) => { wrapper = mountFn(Component, { provide, stubs: { GlSprintf }, @@ -140,11 +141,13 @@ describe('Configure Feature Flags Modal', () => { describe('has rotate error', () => { afterEach(() => wrapper.destroy()); - beforeEach(factory.bind(null, { hasRotateError: false })); + beforeEach(() => { + factory({ hasRotateError: true }); + }); it('should display an error', async () => { - expect(wrapper.find('.text-danger')).toExist(); - expect(wrapper.find('[name="warning"]')).toExist(); + expect(wrapper.findByTestId('rotate-error').exists()).toBe(true); + expect(wrapper.find('[name="warning"]').exists()).toBe(true); }); }); diff --git a/spec/frontend/filterable_list_spec.js b/spec/frontend/filterable_list_spec.js index 556cf6f8137..3fd5d198e3a 100644 --- a/spec/frontend/filterable_list_spec.js +++ b/spec/frontend/filterable_list_spec.js @@ -1,5 +1,4 @@ -// eslint-disable-next-line import/no-deprecated -import { getJSONFixture, setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture } from 'helpers/fixtures'; import FilterableList from '~/filterable_list'; describe('FilterableList', () => { @@ -15,8 +14,6 @@ describe('FilterableList', () => {
`); - // eslint-disable-next-line import/no-deprecated - getJSONFixture('static/projects.json'); form = document.querySelector('form#project-filter-form'); filter = document.querySelector('.js-projects-list-filter'); holder = document.querySelector('.js-projects-list-holder'); diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index 45f73260887..8fd6a5531db 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -2,63 +2,75 @@ # spec/frontend/fixtures/api_markdown.rb and # spec/frontend/content_editor/extensions/markdown_processing_spec.js --- +- name: attachment_image + context: group + markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)' +- name: attachment_image + context: project + markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)' +- name: attachment_image + context: project_wiki + markdown: '![test-file](test-file.png)' +- name: attachment_link + context: group + markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)' +- name: attachment_link + context: project + markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)' +- name: attachment_link + context: project_wiki + markdown: '[test-file](test-file.zip)' +- name: audio + markdown: '![Sample Audio](https://gitlab.com/gitlab.mp3)' +- name: audio_and_video_in_lists + markdown: |- + * ![Sample Audio](https://gitlab.com/1.mp3) + * ![Sample Video](https://gitlab.com/2.mp4) + + 1. ![Sample Video](https://gitlab.com/1.mp4) + 2. ![Sample Audio](https://gitlab.com/2.mp3) + + * [x] ![Sample Audio](https://gitlab.com/1.mp3) + * [x] ![Sample Audio](https://gitlab.com/2.mp3) + * [x] ![Sample Video](https://gitlab.com/3.mp4) +- name: blockquote + markdown: |- + > This is a blockquote + > + > This is another one - name: bold markdown: '**bold**' -- name: emphasis - markdown: '_emphasized text_' -- name: inline_code - markdown: '`code`' -- name: inline_diff +- name: bullet_list_style_1 markdown: |- - * {-deleted-} - * {+added+} -- name: strike - markdown: '~~del~~' -- name: horizontal_rule - markdown: '---' -- name: html_marks + * list item 1 + * list item 2 + * embedded list item 3 +- name: bullet_list_style_2 markdown: |- - * Content editor is ~~great~~amazing. - * If the changes LGTM, please MWPS. - * The English song Oh I do like to be beside the seaside looks like this in Hebrew: אה, אני אוהב להיות ליד חוף הים. In the computer's memory, this is stored as אה, אני אוהב להיות ליד חוף הים. - * The Scream by Edvard Munch. Painted in 1893. - * HTML is the standard markup language for creating web pages. - * Do not forget to buy milk today. - * This is a paragraph and smaller text goes here. - * The concert starts at and you'll be able to enjoy the band for at least . - * Press Ctrl + C to copy text (Windows). - * WWF's goal is to: Build a future where people live in harmony with nature. We hope they succeed. - * The error occured was: Keyboard not found. Press F1 to continue. - * The area of a triangle is: 1/2 x b x h, where b is the base, and h is the vertical height. - * ㄏㄢˋ - * C7H16 + O2 → CO2 + H2O - * The **Pythagorean theorem** is often expressed as a2 + b2 = c2 -- name: div + - list item 1 + - list item 2 + * embedded list item 3 +- name: bullet_list_style_3 markdown: |- -
plain text
-
- - just a plain ol' div, not much to _expect_! - -
-- name: figure + + list item 1 + + list item 2 + - embedded list item 3 +- name: code_block markdown: |- -
- - ![Elephant at sunset](elephant-sunset.jpg) - -
An elephant at sunset
-
-
- - ![A crocodile wearing crocs](croc-crocs.jpg) - -
- - A crocodile wearing _crocs_! - -
-
+ ```javascript + console.log('hello world') + ``` +- name: color_chips + markdown: |- + - `#F00` + - `#F00A` + - `#FF0000` + - `#FF0000AA` + - `RGB(0,255,0)` + - `RGB(0%,100%,0%)` + - `RGBA(0,255,0,0.3)` + - `HSL(540,70%,50%)` + - `HSLA(540,70%,50%,0.3)` - name: description_list markdown: |-
@@ -106,31 +118,57 @@ ``` -- name: link - markdown: '[GitLab](https://gitlab.com)' -- name: attachment_link - context: project_wiki - markdown: '[test-file](test-file.zip)' -- name: attachment_link - context: project - markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)' -- name: attachment_link - context: group - markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)' -- name: attachment_image - context: project_wiki - markdown: '![test-file](test-file.png)' -- name: attachment_image - context: project - markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)' -- name: attachment_image - context: group - markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)' -- name: code_block +- name: div markdown: |- - ```javascript - console.log('hello world') - ``` +
plain text
+
+ + just a plain ol' div, not much to _expect_! + +
+- name: emoji + markdown: ':sparkles: :heart: :100:' +- name: emphasis + markdown: '_emphasized text_' +- name: figure + markdown: |- +
+ + ![Elephant at sunset](elephant-sunset.jpg) + +
An elephant at sunset
+
+
+ + ![A crocodile wearing crocs](croc-crocs.jpg) + +
+ + A crocodile wearing _crocs_! + +
+
+- name: frontmatter_json + markdown: |- + ;;; + { + "title": "Page title" + } + ;;; +- name: frontmatter_toml + markdown: |- + +++ + title = "Page title" + +++ +- name: frontmatter_yaml + markdown: |- + --- + title: Page title + --- +- name: hard_break + markdown: |- + This is a line after a\ + hard break - name: headings markdown: |- # Heading 1 @@ -144,29 +182,44 @@ ##### Heading 5 ###### Heading 6 -- name: blockquote - markdown: |- - > This is a blockquote - > - > This is another one -- name: thematic_break - markdown: |- - --- -- name: bullet_list_style_1 +- name: horizontal_rule + markdown: '---' +- name: html_marks markdown: |- - * list item 1 - * list item 2 - * embedded list item 3 -- name: bullet_list_style_2 + * Content editor is ~~great~~amazing. + * If the changes LGTM, please MWPS. + * The English song Oh I do like to be beside the seaside looks like this in Hebrew: אה, אני אוהב להיות ליד חוף הים. In the computer's memory, this is stored as אה, אני אוהב להיות ליד חוף הים. + * The Scream by Edvard Munch. Painted in 1893. + * HTML is the standard markup language for creating web pages. + * Do not forget to buy milk today. + * This is a paragraph and smaller text goes here. + * The concert starts at and you'll be able to enjoy the band for at least . + * Press Ctrl + C to copy text (Windows). + * WWF's goal is to: Build a future where people live in harmony with nature. We hope they succeed. + * The error occured was: Keyboard not found. Press F1 to continue. + * The area of a triangle is: 1/2 x b x h, where b is the base, and h is the vertical height. + * ㄏㄢˋ + * C7H16 + O2 → CO2 + H2O + * The **Pythagorean theorem** is often expressed as a2 + b2 = c2 +- name: image + markdown: '![alt text](https://gitlab.com/logo.png)' +- name: inline_code + markdown: '`code`' +- name: inline_diff markdown: |- - - list item 1 - - list item 2 - * embedded list item 3 -- name: bullet_list_style_3 + * {-deleted-} + * {+added+} +- name: link + markdown: '[GitLab](https://gitlab.com)' +- name: math markdown: |- - + list item 1 - + list item 2 - - embedded list item 3 + This math is inline $`a^2+b^2=c^2`$. + + This is on a separate line: + + ```math + a^2+b^2=c^2 + ``` - name: ordered_list markdown: |- 1. list item 1 @@ -177,14 +230,6 @@ 134. list item 1 135. list item 2 136. list item 3 -- name: task_list - markdown: |- - * [x] hello - * [x] world - * [ ] example - * [ ] of nested - * [x] task list - * [ ] items - name: ordered_task_list markdown: |- 1. [x] hello @@ -198,12 +243,12 @@ 4893. [x] hello 4894. [x] world 4895. [ ] example -- name: image - markdown: '![alt text](https://gitlab.com/logo.png)' -- name: hard_break +- name: reference + context: project_wiki markdown: |- - This is a line after a\ - hard break + Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1 +- name: strike + markdown: '~~del~~' - name: table markdown: |- | header | header | @@ -212,27 +257,6 @@ | ~~strike~~ | cell with _italic_ | # content after table -- name: emoji - markdown: ':sparkles: :heart: :100:' -- name: reference - context: project_wiki - markdown: |- - Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1 -- name: audio - markdown: '![Sample Audio](https://gitlab.com/gitlab.mp3)' -- name: video - markdown: '![Sample Video](https://gitlab.com/gitlab.mp4)' -- name: audio_and_video_in_lists - markdown: |- - * ![Sample Audio](https://gitlab.com/1.mp3) - * ![Sample Video](https://gitlab.com/2.mp4) - - 1. ![Sample Video](https://gitlab.com/1.mp4) - 2. ![Sample Audio](https://gitlab.com/2.mp3) - - * [x] ![Sample Audio](https://gitlab.com/1.mp3) - * [x] ![Sample Audio](https://gitlab.com/2.mp3) - * [x] ![Sample Video](https://gitlab.com/3.mp4) - name: table_of_contents markdown: |- [[_TOC_]] @@ -248,42 +272,18 @@ # Sit amit ### I don't know -- name: word_break - markdown: Fernstraßenbauprivatfinanzierungsgesetz -- name: frontmatter_yaml - markdown: |- - --- - title: Page title - --- -- name: frontmatter_toml - markdown: |- - +++ - title = "Page title" - +++ -- name: frontmatter_json - markdown: |- - ;;; - { - "title": "Page title" - } - ;;; -- name: color_chips +- name: task_list markdown: |- - - `#F00` - - `#F00A` - - `#FF0000` - - `#FF0000AA` - - `RGB(0,255,0)` - - `RGB(0%,100%,0%)` - - `RGBA(0,255,0,0.3)` - - `HSL(540,70%,50%)` - - `HSLA(540,70%,50%,0.3)` -- name: math + * [x] hello + * [x] world + * [ ] example + * [ ] of nested + * [x] task list + * [ ] items +- name: thematic_break markdown: |- - This math is inline $`a^2+b^2=c^2`$. - - This is on a separate line: - - ```math - a^2+b^2=c^2 - ``` + --- +- name: video + markdown: '![Sample Video](https://gitlab.com/gitlab.mp4)' +- name: word_break + markdown: Fernstraßenbauprivatfinanzierungsgesetz diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index 3c8964d398a..23c18c97df2 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -65,5 +65,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do expect_graphql_errors_to_be_empty end end + + context 'project storage count query' do + before do + project.statistics.update!( + repository_size: 3900000, + lfs_objects_size: 4800000, + build_artifacts_size: 400000, + pipeline_artifacts_size: 400000, + wiki_size: 300000, + packages_size: 3800000, + uploads_size: 900000 + ) + end + + base_input_path = 'projects/storage_counter/queries/' + base_output_path = 'graphql/projects/storage_counter/' + query_name = 'project_storage.query.graphql' + + it "#{base_output_path}#{query_name}.json" do + query = get_graphql_query_as_string("#{base_input_path}#{query_name}") + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path }) + + expect_graphql_errors_to_be_empty + end + end end end diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 96e5202780b..f7bde8d2f16 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -3,6 +3,7 @@ import createFlash, { createAction, hideFlash, removeFlashClickListener, + FLASH_CLOSED_EVENT, } from '~/flash'; describe('Flash', () => { @@ -79,6 +80,16 @@ describe('Flash', () => { expect(el.remove.mock.calls.length).toBe(1); }); + + it(`dispatches ${FLASH_CLOSED_EVENT} event after transitionend event`, () => { + jest.spyOn(el, 'dispatchEvent'); + + hideFlash(el); + + el.dispatchEvent(new Event('transitionend')); + + expect(el.dispatchEvent).toHaveBeenCalledWith(new Event(FLASH_CLOSED_EVENT)); + }); }); describe('createAction', () => { diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index eb11df2fe43..631e3307f7f 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; -import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete'; +import GfmAutoComplete, { membersBeforeSave, highlighter } from 'ee_else_ce/gfm_auto_complete'; import { initEmojiMock } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; import { TEST_HOST } from 'helpers/test_constants'; @@ -858,4 +858,14 @@ describe('GfmAutoComplete', () => { ); }); }); + + describe('highlighter', () => { + it('escapes regex', () => { + const li = '
  • couple (woman,woman)
  • '; + + expect(highlighter(li, ')')).toBe( + '
  • couple (woman,woman)
  • ', + ); + }); + }); }); diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js new file mode 100644 index 00000000000..bb86eb5c22e --- /dev/null +++ b/spec/frontend/google_cloud/components/app_spec.js @@ -0,0 +1,66 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlTab, GlTabs } from '@gitlab/ui'; +import App from '~/google_cloud/components/app.vue'; +import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; +import ServiceAccounts from '~/google_cloud/components/service_accounts.vue'; + +describe('google_cloud App component', () => { + let wrapper; + + const findIncubationBanner = () => wrapper.findComponent(IncubationBanner); + const findTabs = () => wrapper.findComponent(GlTabs); + const findTabItems = () => findTabs().findAllComponents(GlTab); + const findConfigurationTab = () => findTabItems().at(0); + const findDeploymentTab = () => findTabItems().at(1); + const findServicesTab = () => findTabItems().at(2); + const findServiceAccounts = () => findConfigurationTab().findComponent(ServiceAccounts); + + beforeEach(() => { + const propsData = { + serviceAccounts: [{}, {}], + createServiceAccountUrl: '#url-create-service-account', + emptyIllustrationUrl: '#url-empty-illustration', + }; + wrapper = shallowMount(App, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should contain incubation banner', () => { + expect(findIncubationBanner().exists()).toBe(true); + }); + + describe('google_cloud App tabs', () => { + it('should contain tabs', () => { + expect(findTabs().exists()).toBe(true); + }); + + it('should contain three tab items', () => { + expect(findTabItems().length).toBe(3); + }); + + describe('configuration tab', () => { + it('should exist', () => { + expect(findConfigurationTab().exists()).toBe(true); + }); + + it('should contain service accounts component', () => { + expect(findServiceAccounts().exists()).toBe(true); + }); + }); + + describe('deployments tab', () => { + it('should exist', () => { + expect(findDeploymentTab().exists()).toBe(true); + }); + }); + + describe('services tab', () => { + it('should exist', () => { + expect(findServicesTab().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/google_cloud/components/incubation_banner_spec.js b/spec/frontend/google_cloud/components/incubation_banner_spec.js new file mode 100644 index 00000000000..89517be4ef1 --- /dev/null +++ b/spec/frontend/google_cloud/components/incubation_banner_spec.js @@ -0,0 +1,60 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlLink } from '@gitlab/ui'; +import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; + +describe('IncubationBanner component', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLinks = () => wrapper.findAllComponents(GlLink); + const findFeatureRequestLink = () => findLinks().at(0); + const findReportBugLink = () => findLinks().at(1); + const findShareFeedbackLink = () => findLinks().at(2); + + beforeEach(() => { + const propsData = { + shareFeedbackUrl: 'url_general_feedback', + reportBugUrl: 'url_report_bug', + featureRequestUrl: 'url_feature_request', + }; + wrapper = mount(IncubationBanner, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('contains relevant text', () => { + expect(findAlert().text()).toContain( + 'This is an experimental feature developed by GitLab Incubation Engineering.', + ); + }); + + describe('has relevant gl-links', () => { + it('three in total', () => { + expect(findLinks().length).toBe(3); + }); + + it('contains feature request link', () => { + const link = findFeatureRequestLink(); + expect(link.text()).toBe('request a feature'); + expect(link.attributes('href')).toBe('url_feature_request'); + }); + + it('contains report bug link', () => { + const link = findReportBugLink(); + expect(link.text()).toBe('report a bug'); + expect(link.attributes('href')).toBe('url_report_bug'); + }); + + it('contains share feedback link', () => { + const link = findShareFeedbackLink(); + expect(link.text()).toBe('share feedback'); + expect(link.attributes('href')).toBe('url_general_feedback'); + }); + }); +}); diff --git a/spec/frontend/google_cloud/components/service_accounts_spec.js b/spec/frontend/google_cloud/components/service_accounts_spec.js new file mode 100644 index 00000000000..3d097078f03 --- /dev/null +++ b/spec/frontend/google_cloud/components/service_accounts_spec.js @@ -0,0 +1,79 @@ +import { mount } from '@vue/test-utils'; +import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; +import ServiceAccounts from '~/google_cloud/components/service_accounts.vue'; + +describe('ServiceAccounts component', () => { + describe('when the project does not have any service accounts', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findButtonInEmptyState = () => findEmptyState().findComponent(GlButton); + + beforeEach(() => { + const propsData = { + list: [], + createUrl: '#create-url', + emptyIllustrationUrl: '#empty-illustration-url', + }; + wrapper = mount(ServiceAccounts, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows the empty state component', () => { + expect(findEmptyState().exists()).toBe(true); + }); + it('shows the link to create new service accounts', () => { + const button = findButtonInEmptyState(); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Create service account'); + expect(button.attributes('href')).toBe('#create-url'); + }); + }); + + describe('when three service accounts are passed via props', () => { + let wrapper; + + const findTitle = () => wrapper.find('h2'); + const findDescription = () => wrapper.find('p'); + const findTable = () => wrapper.findComponent(GlTable); + const findRows = () => findTable().findAll('tr'); + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + const propsData = { + list: [{}, {}, {}], + createUrl: '#create-url', + emptyIllustrationUrl: '#empty-illustration-url', + }; + wrapper = mount(ServiceAccounts, { propsData }); + }); + + it('shows the title', () => { + expect(findTitle().text()).toBe('Service Accounts'); + }); + + it('shows the description', () => { + expect(findDescription().text()).toBe( + 'Service Accounts keys authorize GitLab to deploy your Google Cloud project', + ); + }); + + it('shows the table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('table must have three rows + header row', () => { + expect(findRows().length).toBe(4); + }); + + it('shows the link to create new service accounts', () => { + const button = findButton(); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Create service account'); + expect(button.attributes('href')).toBe('#create-url'); + }); + }); +}); diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js index 1732f24eeff..9f478eedbfb 100644 --- a/spec/frontend/graphql_shared/utils_spec.js +++ b/spec/frontend/graphql_shared/utils_spec.js @@ -51,6 +51,10 @@ describe('getIdFromGraphQLId', () => { input: 'gid://gitlab/Environments/', output: null, }, + { + input: 'gid://gitlab/Environments/0', + output: 0, + }, { input: 'gid://gitlab/Environments/123', output: 123, diff --git a/spec/frontend/group_settings/components/shared_runners_form_spec.js b/spec/frontend/group_settings/components/shared_runners_form_spec.js index 78950a8fe20..617d91178e4 100644 --- a/spec/frontend/group_settings/components/shared_runners_form_spec.js +++ b/spec/frontend/group_settings/components/shared_runners_form_spec.js @@ -3,13 +3,16 @@ import { shallowMount } from '@vue/test-utils'; import MockAxiosAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import SharedRunnersForm from '~/group_settings/components/shared_runners_form.vue'; -import { ENABLED, DISABLED, ALLOW_OVERRIDE } from '~/group_settings/constants'; import axios from '~/lib/utils/axios_utils'; -const TEST_UPDATE_PATH = '/test/update'; -const DISABLED_PAYLOAD = { shared_runners_setting: DISABLED }; -const ENABLED_PAYLOAD = { shared_runners_setting: ENABLED }; -const OVERRIDE_PAYLOAD = { shared_runners_setting: ALLOW_OVERRIDE }; +const provide = { + updatePath: '/test/update', + sharedRunnersAvailability: 'enabled', + parentSharedRunnersAvailability: null, + runnerDisabled: 'disabled', + runnerEnabled: 'enabled', + runnerAllowOverride: 'allow_override', +}; jest.mock('~/flash'); @@ -17,13 +20,11 @@ describe('group_settings/components/shared_runners_form', () => { let wrapper; let mock; - const createComponent = (props = {}) => { + const createComponent = (provides = {}) => { wrapper = shallowMount(SharedRunnersForm, { - propsData: { - updatePath: TEST_UPDATE_PATH, - sharedRunnersAvailability: ENABLED, - parentSharedRunnersAvailability: null, - ...props, + provide: { + ...provide, + ...provides, }, }); }; @@ -33,13 +34,13 @@ describe('group_settings/components/shared_runners_form', () => { const findEnabledToggle = () => wrapper.find('[data-testid="enable-runners-toggle"]'); const findOverrideToggle = () => wrapper.find('[data-testid="override-runners-toggle"]'); const changeToggle = (toggle) => toggle.vm.$emit('change', !toggle.props('value')); - const getRequestPayload = () => JSON.parse(mock.history.put[0].data); + const getSharedRunnersSetting = () => JSON.parse(mock.history.put[0].data).shared_runners_setting; const isLoadingIconVisible = () => findLoadingIcon().exists(); beforeEach(() => { mock = new MockAxiosAdapter(axios); - mock.onPut(TEST_UPDATE_PATH).reply(200); + mock.onPut(provide.updatePath).reply(200); }); afterEach(() => { @@ -95,7 +96,7 @@ describe('group_settings/components/shared_runners_form', () => { await waitForPromises(); - expect(getRequestPayload()).toEqual(ENABLED_PAYLOAD); + expect(getSharedRunnersSetting()).toEqual(provide.runnerEnabled); expect(findOverrideToggle().exists()).toBe(false); }); @@ -104,14 +105,14 @@ describe('group_settings/components/shared_runners_form', () => { await waitForPromises(); - expect(getRequestPayload()).toEqual(DISABLED_PAYLOAD); + expect(getSharedRunnersSetting()).toEqual(provide.runnerDisabled); expect(findOverrideToggle().exists()).toBe(true); }); }); describe('override toggle', () => { beforeEach(() => { - createComponent({ sharedRunnersAvailability: ALLOW_OVERRIDE }); + createComponent({ sharedRunnersAvailability: provide.runnerAllowOverride }); }); it('enabling the override toggle sends correct payload', async () => { @@ -119,7 +120,7 @@ describe('group_settings/components/shared_runners_form', () => { await waitForPromises(); - expect(getRequestPayload()).toEqual(OVERRIDE_PAYLOAD); + expect(getSharedRunnersSetting()).toEqual(provide.runnerAllowOverride); }); it('disabling the override toggle sends correct payload', async () => { @@ -127,21 +128,21 @@ describe('group_settings/components/shared_runners_form', () => { await waitForPromises(); - expect(getRequestPayload()).toEqual(DISABLED_PAYLOAD); + expect(getSharedRunnersSetting()).toEqual(provide.runnerDisabled); }); }); describe('toggle disabled state', () => { - it(`toggles are not disabled with setting ${DISABLED}`, () => { - createComponent({ sharedRunnersAvailability: DISABLED }); + it(`toggles are not disabled with setting ${provide.runnerDisabled}`, () => { + createComponent({ sharedRunnersAvailability: provide.runnerDisabled }); expect(findEnabledToggle().props('disabled')).toBe(false); expect(findOverrideToggle().props('disabled')).toBe(false); }); it('toggles are disabled', () => { createComponent({ - sharedRunnersAvailability: DISABLED, - parentSharedRunnersAvailability: DISABLED, + sharedRunnersAvailability: provide.runnerDisabled, + parentSharedRunnersAvailability: provide.runnerDisabled, }); expect(findEnabledToggle().props('disabled')).toBe(true); expect(findOverrideToggle().props('disabled')).toBe(true); @@ -154,7 +155,7 @@ describe('group_settings/components/shared_runners_form', () => { ${{ error: 'Undefined error' }} | ${'Undefined error Refresh the page and try again.'} `(`with error $errorObj`, ({ errorObj, message }) => { beforeEach(async () => { - mock.onPut(TEST_UPDATE_PATH).reply(500, errorObj); + mock.onPut(provide.updatePath).reply(500, errorObj); createComponent(); changeToggle(findEnabledToggle()); diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap index 194a619c4aa..47e3a56e83d 100644 --- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap +++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap @@ -8,7 +8,7 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli
    diff --git a/spec/frontend/ide/components/shared/commit_message_field_spec.js b/spec/frontend/ide/components/shared/commit_message_field_spec.js new file mode 100644 index 00000000000..f4f9b95b233 --- /dev/null +++ b/spec/frontend/ide/components/shared/commit_message_field_spec.js @@ -0,0 +1,149 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommitMessageField from '~/ide/components/shared/commit_message_field.vue'; + +const DEFAULT_PROPS = { + text: 'foo text', + placeholder: 'foo placeholder', +}; + +describe('CommitMessageField', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + shallowMount(CommitMessageField, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + attachTo: document.body, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findTextArea = () => wrapper.find('textarea'); + const findHighlights = () => wrapper.findByTestId('highlights'); + const findHighlightsText = () => wrapper.findByTestId('highlights-text'); + const findHighlightsMark = () => wrapper.findByTestId('highlights-mark'); + const findHighlightsTexts = () => wrapper.findAllByTestId('highlights-text'); + const findHighlightsMarks = () => wrapper.findAllByTestId('highlights-mark'); + + const fillText = async (text) => { + wrapper.setProps({ text }); + await nextTick(); + }; + + it('emits input event on input', () => { + const value = 'foo'; + + createComponent(); + findTextArea().setValue(value); + expect(wrapper.emitted('input')[0][0]).toEqual(value); + }); + + describe('focus classes', () => { + beforeEach(async () => { + createComponent(); + findTextArea().trigger('focus'); + await nextTick(); + }); + + it('is added on textarea focus', async () => { + expect(wrapper.attributes('class')).toEqual( + expect.stringContaining('gl-outline-none! gl-focus-ring-border-1-gray-900!'), + ); + }); + + it('is removed on textarea blur', async () => { + findTextArea().trigger('blur'); + await nextTick(); + + expect(wrapper.attributes('class')).toEqual( + expect.not.stringContaining('gl-outline-none! gl-focus-ring-border-1-gray-900!'), + ); + }); + }); + + describe('highlights', () => { + describe('subject line', () => { + it('does not highlight less than 50 characters', async () => { + const text = 'text less than 50 chars'; + + createComponent(); + await fillText(text); + + expect(findHighlightsText().text()).toEqual(text); + expect(findHighlightsMark().text()).toBeFalsy(); + }); + + it('highlights characters over 50 length', async () => { + const text = + 'text less than 50 chars that should not highlighted. text more than 50 should be highlighted'; + + createComponent(); + await fillText(text); + + expect(findHighlightsText().text()).toEqual(text.slice(0, 50)); + expect(findHighlightsMark().text()).toEqual(text.slice(50)); + }); + }); + + describe('body text', () => { + it('does not highlight body text less tan 72 characters', async () => { + const text = 'subject line\nbody content'; + + createComponent(); + await fillText(text); + + expect(findHighlightsTexts()).toHaveLength(2); + expect(findHighlightsMarks().at(1).attributes('style')).toEqual('display: none;'); + }); + + it('highlights body text more than 72 characters', async () => { + const text = + 'subject line\nbody content that will be highlighted when it is more than 72 characters in length'; + + createComponent(); + await fillText(text); + + expect(findHighlightsTexts()).toHaveLength(2); + expect(findHighlightsMarks().at(1).attributes('style')).not.toEqual('display: none;'); + expect(findHighlightsMarks().at(1).element.textContent).toEqual(' in length'); + }); + + it('highlights body text & subject line', async () => { + const text = + 'text less than 50 chars that should not highlighted\nbody content that will be highlighted when it is more than 72 characters in length'; + + createComponent(); + await fillText(text); + + expect(findHighlightsTexts()).toHaveLength(2); + expect(findHighlightsMarks()).toHaveLength(2); + expect(findHighlightsMarks().at(0).element.textContent).toEqual('d'); + expect(findHighlightsMarks().at(1).element.textContent).toEqual(' in length'); + }); + }); + }); + + describe('scrolling textarea', () => { + it('updates transform of highlights', async () => { + const yCoord = 50; + + createComponent(); + await fillText('subject line\n\n\n\n\n\n\n\n\n\n\nbody content'); + + wrapper.vm.$el.querySelector('textarea').scrollTo(0, yCoord); + await nextTick(); + + expect(wrapper.vm.scrollTop).toEqual(yCoord); + expect(findHighlights().attributes('style')).toEqual('transform: translate3d(0, -50px, 0);'); + }); + }); +}); diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js index 23fe23bdef9..4602a0837e0 100644 --- a/spec/frontend/ide/stores/mutations_spec.js +++ b/spec/frontend/ide/stores/mutations_spec.js @@ -86,12 +86,12 @@ describe('Multi-file store mutations', () => { mutations.SET_EMPTY_STATE_SVGS(localState, { emptyStateSvgPath: 'emptyState', noChangesStateSvgPath: 'noChanges', - committedStateSvgPath: 'commited', + committedStateSvgPath: 'committed', }); expect(localState.emptyStateSvgPath).toBe('emptyState'); expect(localState.noChangesStateSvgPath).toBe('noChanges'); - expect(localState.committedStateSvgPath).toBe('commited'); + expect(localState.committedStateSvgPath).toBe('committed'); }); }); diff --git a/spec/frontend/import_entities/components/group_dropdown_spec.js b/spec/frontend/import_entities/components/group_dropdown_spec.js index f7aa0e889ea..1c1e1e7ebd4 100644 --- a/spec/frontend/import_entities/components/group_dropdown_spec.js +++ b/spec/frontend/import_entities/components/group_dropdown_spec.js @@ -24,14 +24,21 @@ describe('Import entities group dropdown component', () => { }); it('passes namespaces from props to default slot', () => { - const namespaces = ['ns1', 'ns2']; + const namespaces = [ + { id: 1, fullPath: 'ns1' }, + { id: 2, fullPath: 'ns2' }, + ]; createComponent({ namespaces }); expect(namespacesTracker).toHaveBeenCalledWith({ namespaces }); }); it('filters namespaces based on user input', async () => { - const namespaces = ['match1', 'some unrelated', 'match2']; + const namespaces = [ + { id: 1, fullPath: 'match1' }, + { id: 2, fullPath: 'some unrelated' }, + { id: 3, fullPath: 'match2' }, + ]; createComponent({ namespaces }); namespacesTracker.mockReset(); @@ -39,6 +46,11 @@ describe('Import entities group dropdown component', () => { await nextTick(); - expect(namespacesTracker).toHaveBeenCalledWith({ namespaces: ['match1', 'match2'] }); + expect(namespacesTracker).toHaveBeenCalledWith({ + namespaces: [ + { id: 1, fullPath: 'match1' }, + { id: 3, fullPath: 'match2' }, + ], + }); }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js index 60f0780fdb3..cd56f573011 100644 --- a/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js @@ -1,8 +1,6 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { STATUSES } from '~/import_entities/constants'; import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue'; -import { generateFakeEntry } from '../graphql/fixtures'; describe('import actions cell', () => { let wrapper; @@ -10,7 +8,9 @@ describe('import actions cell', () => { const createComponent = (props) => { wrapper = shallowMount(ImportActionsCell, { propsData: { - groupPathRegex: /^[a-zA-Z]+$/, + isFinished: false, + isAvailableForImport: false, + isInvalid: false, ...props, }, }); @@ -20,10 +20,9 @@ describe('import actions cell', () => { wrapper.destroy(); }); - describe('when import status is NONE', () => { + describe('when group is available for import', () => { beforeEach(() => { - const group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); - createComponent({ group }); + createComponent({ isAvailableForImport: true }); }); it('renders import button', () => { @@ -37,10 +36,9 @@ describe('import actions cell', () => { }); }); - describe('when import status is FINISHED', () => { + describe('when group is finished', () => { beforeEach(() => { - const group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED }); - createComponent({ group }); + createComponent({ isAvailableForImport: true, isFinished: true }); }); it('renders re-import button', () => { @@ -58,29 +56,22 @@ describe('import actions cell', () => { }); }); - it('does not render import button when group import is in progress', () => { - const group = generateFakeEntry({ id: 1, status: STATUSES.STARTED }); - createComponent({ group }); + it('does not render import button when group is not available for import', () => { + createComponent({ isAvailableForImport: false }); const button = wrapper.findComponent(GlButton); expect(button.exists()).toBe(false); }); - it('renders import button as disabled when there are validation errors', () => { - const group = generateFakeEntry({ - id: 1, - status: STATUSES.NONE, - validation_errors: [{ field: 'new_name', message: 'something ' }], - }); - createComponent({ group }); + it('renders import button as disabled when group is invalid', () => { + createComponent({ isInvalid: true, isAvailableForImport: true }); const button = wrapper.findComponent(GlButton); expect(button.props().disabled).toBe(true); }); it('emits import-group event when import button is clicked', () => { - const group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); - createComponent({ group }); + createComponent({ isAvailableForImport: true }); const button = wrapper.findComponent(GlButton); button.vm.$emit('click'); diff --git a/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js index 2a56efd1cbb..f2735d86493 100644 --- a/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js @@ -4,6 +4,11 @@ import { STATUSES } from '~/import_entities/constants'; import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue'; import { generateFakeEntry } from '../graphql/fixtures'; +const generateFakeTableEntry = ({ flags = {}, ...entry }) => ({ + ...generateFakeEntry(entry), + flags, +}); + describe('import source cell', () => { let wrapper; let group; @@ -23,14 +28,14 @@ describe('import source cell', () => { describe('when group status is NONE', () => { beforeEach(() => { - group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE }); createComponent({ group }); }); it('renders link to a group', () => { const link = wrapper.findComponent(GlLink); - expect(link.attributes().href).toBe(group.web_url); - expect(link.text()).toContain(group.full_path); + expect(link.attributes().href).toBe(group.webUrl); + expect(link.text()).toContain(group.fullPath); }); it('does not render last imported line', () => { @@ -40,20 +45,24 @@ describe('import source cell', () => { describe('when group status is FINISHED', () => { beforeEach(() => { - group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED }); + group = generateFakeTableEntry({ + id: 1, + status: STATUSES.FINISHED, + flags: { + isFinished: true, + }, + }); createComponent({ group }); }); it('renders link to a group', () => { const link = wrapper.findComponent(GlLink); - expect(link.attributes().href).toBe(group.web_url); - expect(link.text()).toContain(group.full_path); + expect(link.attributes().href).toBe(group.webUrl); + expect(link.text()).toContain(group.fullPath); }); it('renders last imported line', () => { - expect(wrapper.text()).toMatchInterpolatedText( - 'fake_group_1 Last imported to root/last-group1', - ); + expect(wrapper.text()).toMatchInterpolatedText('fake_group_1 Last imported to root/group1'); }); }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index f43e545e049..6e3df21e30a 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -1,39 +1,30 @@ -import { - GlButton, - GlEmptyState, - GlLoadingIcon, - GlSearchBoxByClick, - GlDropdown, - GlDropdownItem, - GlTable, -} from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import MockAdapter from 'axios-mock-adapter'; import createMockApollo from 'helpers/mock_apollo_helper'; -import stubChildren from 'helpers/stub_children'; -import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import httpStatus from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; import { STATUSES } from '~/import_entities/constants'; -import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue'; +import { i18n } from '~/import_entities/import_groups/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; -import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; -import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +jest.mock('~/flash'); +jest.mock('~/import_entities/import_groups/services/status_poller'); -const GlDropdownStub = stubComponent(GlDropdown, { - template: '

    ', -}); +Vue.use(VueApollo); describe('import table', () => { let wrapper; let apolloProvider; + let axiosMock; const SOURCE_URL = 'https://demo.host'; const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE }); @@ -44,76 +35,81 @@ describe('import table', () => { const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; const findImportSelectedButton = () => - wrapper.findAllComponents(GlButton).wrappers.find((w) => w.text() === 'Import selected'); - const findPaginationDropdown = () => wrapper.findComponent(GlDropdown); - const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text(); + wrapper.findAll('button').wrappers.find((w) => w.text() === 'Import selected'); + const findImportButtons = () => + wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import'); + const findPaginationDropdown = () => wrapper.find('[aria-label="Page size"]'); + const findPaginationDropdownText = () => findPaginationDropdown().find('button').text(); - // TODO: remove this ugly approach when - // issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531 - const findTable = () => wrapper.vm.getTableRef(); + const selectRow = (idx) => + wrapper.findAll('tbody td input[type=checkbox]').at(idx).trigger('click'); - const createComponent = ({ bulkImportSourceGroups }) => { + const createComponent = ({ bulkImportSourceGroups, importGroups }) => { apolloProvider = createMockApollo([], { Query: { availableNamespaces: () => availableNamespacesFixture, bulkImportSourceGroups, }, Mutation: { - setTargetNamespace: jest.fn(), - setNewName: jest.fn(), - importGroup: jest.fn(), + importGroups, }, }); wrapper = mount(ImportTable, { propsData: { groupPathRegex: /.*/, + jobsPath: '/fake_job_path', sourceUrl: SOURCE_URL, - groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.', - }, - stubs: { - ...stubChildren(ImportTable), - GlSprintf: false, - GlDropdown: GlDropdownStub, - GlTable: false, }, - localVue, apolloProvider, }); }; + beforeAll(() => { + gon.api_version = 'v4'; + }); + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + axiosMock.onGet(/.*\/exists$/, () => []).reply(200); + }); + afterEach(() => { wrapper.destroy(); }); - it('renders loading icon while performing request', async () => { - createComponent({ - bulkImportSourceGroups: () => new Promise(() => {}), + describe('loading state', () => { + it('renders loading icon while performing request', async () => { + createComponent({ + bulkImportSourceGroups: () => new Promise(() => {}), + }); + await waitForPromises(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); - await waitForPromises(); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - }); + it('does not renders loading icon when request is completed', async () => { + createComponent({ + bulkImportSourceGroups: () => [], + }); + await waitForPromises(); - it('does not renders loading icon when request is completed', async () => { - createComponent({ - bulkImportSourceGroups: () => [], + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); }); - await waitForPromises(); - - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); }); - it('renders message about empty state when no groups are available for import', async () => { - createComponent({ - bulkImportSourceGroups: () => ({ - nodes: [], - pageInfo: FAKE_PAGE_INFO, - }), - }); - await waitForPromises(); + describe('empty state', () => { + it('renders message about empty state when no groups are available for import', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: [], + pageInfo: FAKE_PAGE_INFO, + }), + }); + await waitForPromises(); - expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import'); + expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import'); + }); }); it('renders import row for each group in response', async () => { @@ -140,40 +136,51 @@ describe('import table', () => { expect(wrapper.text()).not.toContain('Showing 1-0'); }); - describe('converts row events to mutation invocations', () => { - beforeEach(() => { - createComponent({ - bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }), - }); - return waitForPromises(); + it('invokes importGroups mutation when row button is clicked', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }), }); - it.each` - event | payload | mutation | variables - ${'update-target-namespace'} | ${'new-namespace'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace', newName: 'group1' }} - ${'update-new-name'} | ${'new-name'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'root', newName: 'new-name' }} - `('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => { - jest.spyOn(apolloProvider.defaultClient, 'mutate'); - wrapper.find(ImportTargetCell).vm.$emit(event, payload); - await waitForPromises(); - expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ - mutation, - variables, - }); - }); + jest.spyOn(apolloProvider.defaultClient, 'mutate'); - it('invokes importGroups mutation when row button is clicked', async () => { - jest.spyOn(apolloProvider.defaultClient, 'mutate'); + await waitForPromises(); - wrapper.findComponent(ImportActionsCell).vm.$emit('import-group'); - await waitForPromises(); - expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ - mutation: importGroupsMutation, - variables: { sourceGroupIds: [FAKE_GROUP.id] }, - }); + await findImportButtons()[0].trigger('click'); + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: importGroupsMutation, + variables: { + importRequests: [ + { + newName: FAKE_GROUP.lastImportTarget.newName, + sourceGroupId: FAKE_GROUP.id, + targetNamespace: availableNamespacesFixture[0].fullPath, + }, + ], + }, }); }); + it('displays error if importing group fails', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }), + importGroups: () => { + throw new Error(); + }, + }); + + axiosMock.onPost('/import/bulk_imports.json').reply(httpStatus.BAD_REQUEST); + + await waitForPromises(); + await findImportButtons()[0].trigger('click'); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: i18n.ERROR_IMPORT, + }), + ); + }); + describe('pagination', () => { const bulkImportSourceGroupsQueryMock = jest .fn() @@ -195,10 +202,10 @@ describe('import table', () => { }); it('updates page size when selected in Dropdown', async () => { - const otherOption = wrapper.findAllComponents(GlDropdownItem).at(1); + const otherOption = findPaginationDropdown().findAll('li p').at(1); expect(otherOption.text()).toMatchInterpolatedText('50 items per page'); - otherOption.vm.$emit('click'); + await otherOption.trigger('click'); await waitForPromises(); expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page'); @@ -247,7 +254,11 @@ describe('import table', () => { return waitForPromises(); }); - const findFilterInput = () => wrapper.find(GlSearchBoxByClick); + const setFilter = (value) => { + const input = wrapper.find('input[placeholder="Filter by source group"]'); + input.setValue(value); + return input.trigger('keydown.enter'); + }; it('properly passes filter to graphql query when search box is submitted', async () => { createComponent({ @@ -256,7 +267,7 @@ describe('import table', () => { await waitForPromises(); const FILTER_VALUE = 'foo'; - findFilterInput().vm.$emit('submit', FILTER_VALUE); + await setFilter(FILTER_VALUE); await waitForPromises(); expect(bulkImportSourceGroupsQueryMock).toHaveBeenCalledWith( @@ -274,7 +285,7 @@ describe('import table', () => { await waitForPromises(); const FILTER_VALUE = 'foo'; - findFilterInput().vm.$emit('submit', FILTER_VALUE); + await setFilter(FILTER_VALUE); await waitForPromises(); expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo" from'); @@ -282,12 +293,14 @@ describe('import table', () => { it('properly resets filter in graphql query when search box is cleared', async () => { const FILTER_VALUE = 'foo'; - findFilterInput().vm.$emit('submit', FILTER_VALUE); + await setFilter(FILTER_VALUE); await waitForPromises(); bulkImportSourceGroupsQueryMock.mockClear(); await apolloProvider.defaultClient.resetStore(); - findFilterInput().vm.$emit('clear'); + + await setFilter(''); + await waitForPromises(); expect(bulkImportSourceGroupsQueryMock).toHaveBeenCalledWith( @@ -320,8 +333,8 @@ describe('import table', () => { }), }); await waitForPromises(); - wrapper.find(GlTable).vm.$emit('row-selected', [FAKE_GROUPS[0]]); - await nextTick(); + + await selectRow(0); expect(findImportSelectedButton().props().disabled).toBe(false); }); @@ -337,7 +350,7 @@ describe('import table', () => { }); await waitForPromises(); - findTable().selectRow(0); + await selectRow(0); await nextTick(); expect(findImportSelectedButton().props().disabled).toBe(true); @@ -348,7 +361,6 @@ describe('import table', () => { generateFakeEntry({ id: 2, status: STATUSES.NONE, - validation_errors: [{ field: 'new_name', message: 'FAKE_VALIDATION_ERROR' }], }), ]; @@ -360,9 +372,9 @@ describe('import table', () => { }); await waitForPromises(); - // TODO: remove this ugly approach when - // issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531 - findTable().selectRow(0); + await wrapper.find('tbody input[aria-label="New name"]').setValue(''); + jest.runOnlyPendingTimers(); + await selectRow(0); await nextTick(); expect(findImportSelectedButton().props().disabled).toBe(true); @@ -384,15 +396,28 @@ describe('import table', () => { jest.spyOn(apolloProvider.defaultClient, 'mutate'); await waitForPromises(); - findTable().selectRow(0); - findTable().selectRow(1); + await selectRow(0); + await selectRow(1); await nextTick(); - findImportSelectedButton().vm.$emit('click'); + await findImportSelectedButton().trigger('click'); expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ mutation: importGroupsMutation, - variables: { sourceGroupIds: [NEW_GROUPS[0].id, NEW_GROUPS[1].id] }, + variables: { + importRequests: [ + { + targetNamespace: availableNamespacesFixture[0].fullPath, + newName: NEW_GROUPS[0].lastImportTarget.newName, + sourceGroupId: NEW_GROUPS[0].id, + }, + { + targetNamespace: availableNamespacesFixture[0].fullPath, + newName: NEW_GROUPS[1].lastImportTarget.newName, + sourceGroupId: NEW_GROUPS[1].id, + }, + ], + }, }); }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js index be83a61841f..3c2367e22f5 100644 --- a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js @@ -3,20 +3,20 @@ import { shallowMount } from '@vue/test-utils'; import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; import { STATUSES } from '~/import_entities/constants'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; -import { availableNamespacesFixture } from '../graphql/fixtures'; - -const getFakeGroup = (status) => ({ - web_url: 'https://fake.host/', - full_path: 'fake_group_1', - full_name: 'fake_name_1', - import_target: { - target_namespace: 'root', - new_name: 'group1', - }, - id: 1, - validation_errors: [], - progress: { status }, -}); +import { generateFakeEntry, availableNamespacesFixture } from '../graphql/fixtures'; + +const generateFakeTableEntry = ({ flags = {}, ...config }) => { + const entry = generateFakeEntry(config); + + return { + ...entry, + importTarget: { + targetNamespace: availableNamespacesFixture[0], + newName: entry.lastImportTarget.newName, + }, + flags, + }; +}; describe('import target cell', () => { let wrapper; @@ -31,7 +31,6 @@ describe('import target cell', () => { propsData: { availableNamespaces: availableNamespacesFixture, groupPathRegex: /.*/, - groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.', ...props, }, }); @@ -44,11 +43,11 @@ describe('import target cell', () => { describe('events', () => { beforeEach(() => { - group = getFakeGroup(STATUSES.NONE); + group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE }); createComponent({ group }); }); - it('invokes $event', () => { + it('emits update-new-name when input value is changed', () => { findNameInput().vm.$emit('input', 'demo'); expect(wrapper.emitted('update-new-name')).toBeDefined(); expect(wrapper.emitted('update-new-name')[0][0]).toBe('demo'); @@ -56,18 +55,23 @@ describe('import target cell', () => { it('emits update-target-namespace when dropdown option is clicked', () => { const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2); - const dropdownItemText = dropdownItem.text(); dropdownItem.vm.$emit('click'); expect(wrapper.emitted('update-target-namespace')).toBeDefined(); - expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText); + expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(availableNamespacesFixture[1]); }); }); describe('when entity status is NONE', () => { beforeEach(() => { - group = getFakeGroup(STATUSES.NONE); + group = generateFakeTableEntry({ + id: 1, + status: STATUSES.NONE, + flags: { + isAvailableForImport: true, + }, + }); createComponent({ group }); }); @@ -78,7 +82,7 @@ describe('import target cell', () => { it('renders only no parent option if available namespaces list is empty', () => { createComponent({ - group: getFakeGroup(STATUSES.NONE), + group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }), availableNamespaces: [], }); @@ -92,7 +96,7 @@ describe('import target cell', () => { it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => { createComponent({ - group: getFakeGroup(STATUSES.NONE), + group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }), availableNamespaces: availableNamespacesFixture, }); @@ -104,9 +108,12 @@ describe('import target cell', () => { expect(rest).toHaveLength(availableNamespacesFixture.length); }); - describe('when entity status is SCHEDULING', () => { + describe('when entity is not available for import', () => { beforeEach(() => { - group = getFakeGroup(STATUSES.SCHEDULING); + group = generateFakeTableEntry({ + id: 1, + flags: { isAvailableForImport: false }, + }); createComponent({ group }); }); @@ -115,9 +122,9 @@ describe('import target cell', () => { }); }); - describe('when entity status is FINISHED', () => { + describe('when entity is available for import', () => { beforeEach(() => { - group = getFakeGroup(STATUSES.FINISHED); + group = generateFakeTableEntry({ id: 1, flags: { isAvailableForImport: true } }); createComponent({ group }); }); @@ -125,41 +132,4 @@ describe('import target cell', () => { expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined); }); }); - - describe('validations', () => { - it('reports invalid group name when name is not matching regex', () => { - createComponent({ - group: { - ...getFakeGroup(STATUSES.NONE), - import_target: { - target_namespace: 'root', - new_name: 'very`bad`name', - }, - }, - groupPathRegex: /^[a-zA-Z]+$/, - }); - - expect(wrapper.text()).toContain( - 'Please choose a group URL with no special characters or spaces.', - ); - }); - - it('reports invalid group name if relevant validation error exists', async () => { - const FAKE_ERROR_MESSAGE = 'fake error'; - - createComponent({ - group: { - ...getFakeGroup(STATUSES.NONE), - validation_errors: [ - { - field: 'new_name', - message: FAKE_ERROR_MESSAGE, - }, - ], - }, - }); - - expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE); - }); - }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index e1d65095888..f3447494578 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -2,32 +2,27 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import MockAdapter from 'axios-mock-adapter'; import { createMockClient } from 'mock-apollo-client'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; import { STATUSES } from '~/import_entities/constants'; import { clientTypenames, createResolvers, } from '~/import_entities/import_groups/graphql/client_factory'; -import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql'; +import { LocalStorageCache } from '~/import_entities/import_groups/graphql/services/local_storage_cache'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; -import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql'; -import setImportProgressMutation from '~/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql'; -import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql'; import updateImportStatusMutation from '~/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql'; import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql'; -import bulkImportSourceGroupQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql'; import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; -import groupAndProjectQuery from '~/import_entities/import_groups/graphql/queries/group_and_project.query.graphql'; -import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { statusEndpointFixture, availableNamespacesFixture } from './fixtures'; jest.mock('~/flash'); -jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({ - StatusPoller: jest.fn().mockImplementation(function mock() { - this.startPolling = jest.fn(); +jest.mock('~/import_entities/import_groups/graphql/services/local_storage_cache', () => ({ + LocalStorageCache: jest.fn().mockImplementation(function mock() { + this.get = jest.fn(); + this.set = jest.fn(); + this.updateStatusByJobId = jest.fn(); }), })); @@ -38,13 +33,6 @@ const FAKE_ENDPOINTS = { jobs: '/fake_jobs', }; -const FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER = jest.fn().mockResolvedValue({ - data: { - existingGroup: null, - existingProject: null, - }, -}); - describe('Bulk import resolvers', () => { let axiosMockAdapter; let client; @@ -58,14 +46,28 @@ describe('Bulk import resolvers', () => { resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }), }); - mockedClient.setRequestHandler(groupAndProjectQuery, FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER); - return mockedClient; }; - beforeEach(() => { + let results; + beforeEach(async () => { axiosMockAdapter = new MockAdapter(axios); client = createClient(); + + axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture); + axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply( + httpStatus.OK, + availableNamespacesFixture.map((ns) => ({ + id: ns.id, + full_path: ns.fullPath, + })), + ); + + client.watchQuery({ query: bulkImportSourceGroupsQuery }).subscribe(({ data }) => { + results = data.bulkImportSourceGroups.nodes; + }); + + return waitForPromises(); }); afterEach(() => { @@ -74,104 +76,41 @@ describe('Bulk import resolvers', () => { describe('queries', () => { describe('availableNamespaces', () => { - let results; - + let namespacesResults; beforeEach(async () => { - axiosMockAdapter - .onGet(FAKE_ENDPOINTS.availableNamespaces) - .reply(httpStatus.OK, availableNamespacesFixture); - const response = await client.query({ query: availableNamespacesQuery }); - results = response.data.availableNamespaces; + namespacesResults = response.data.availableNamespaces; }); it('mirrors REST endpoint response fields', () => { const extractRelevantFields = (obj) => ({ id: obj.id, full_path: obj.full_path }); - expect(results.map(extractRelevantFields)).toStrictEqual( + expect(namespacesResults.map(extractRelevantFields)).toStrictEqual( availableNamespacesFixture.map(extractRelevantFields), ); }); }); - describe('bulkImportSourceGroup', () => { - beforeEach(async () => { - axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture); - axiosMockAdapter - .onGet(FAKE_ENDPOINTS.availableNamespaces) - .reply(httpStatus.OK, availableNamespacesFixture); - - return client.query({ - query: bulkImportSourceGroupsQuery, - }); - }); - - it('returns group', async () => { - const { id } = statusEndpointFixture.importable_data[0]; - const { - data: { bulkImportSourceGroup: group }, - } = await client.query({ - query: bulkImportSourceGroupQuery, - variables: { id: id.toString() }, - }); - - expect(group).toMatchObject(statusEndpointFixture.importable_data[0]); - }); - }); - describe('bulkImportSourceGroups', () => { - let results; - - beforeEach(async () => { - axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture); - axiosMockAdapter - .onGet(FAKE_ENDPOINTS.availableNamespaces) - .reply(httpStatus.OK, availableNamespacesFixture); - }); - it('respects cached import state when provided by group manager', async () => { - const FAKE_JOB_ID = '1'; - const FAKE_STATUS = 'DEMO_STATUS'; - const FAKE_IMPORT_TARGET = { - new_name: 'test-name', - target_namespace: 'test-namespace', + const [localStorageCache] = LocalStorageCache.mock.instances; + const CACHED_DATA = { + progress: { + id: 'DEMO', + status: 'cached', + }, }; - const TARGET_INDEX = 0; + localStorageCache.get.mockReturnValueOnce(CACHED_DATA); - const clientWithMockedManager = createClient({ - GroupsManager: jest.fn().mockImplementation(() => ({ - getImportStateFromStorageByGroupId(groupId) { - if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) { - return { - jobId: FAKE_JOB_ID, - importState: { - status: FAKE_STATUS, - importTarget: FAKE_IMPORT_TARGET, - }, - }; - } - - return null; - }, - })), - }); - - const clientResponse = await clientWithMockedManager.query({ + const updatedResults = await client.query({ query: bulkImportSourceGroupsQuery, + fetchPolicy: 'no-cache', }); - const clientResults = clientResponse.data.bulkImportSourceGroups.nodes; - - expect(clientResults[TARGET_INDEX].import_target).toStrictEqual(FAKE_IMPORT_TARGET); - expect(clientResults[TARGET_INDEX].progress.status).toBe(FAKE_STATUS); - }); - - it('populates each result instance with empty import_target when there are no available namespaces', async () => { - axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []); - - const response = await client.query({ query: bulkImportSourceGroupsQuery }); - results = response.data.bulkImportSourceGroups.nodes; - expect(results.every((r) => r.import_target.target_namespace === '')).toBe(true); + expect(updatedResults.data.bulkImportSourceGroups.nodes[0].progress).toStrictEqual({ + __typename: clientTypenames.BulkImportProgress, + ...CACHED_DATA.progress, + }); }); describe('when called', () => { @@ -181,37 +120,23 @@ describe('Bulk import resolvers', () => { }); it('mirrors REST endpoint response fields', () => { - const MIRRORED_FIELDS = ['id', 'full_name', 'full_path', 'web_url']; + const MIRRORED_FIELDS = [ + { from: 'id', to: 'id' }, + { from: 'full_name', to: 'fullName' }, + { from: 'full_path', to: 'fullPath' }, + { from: 'web_url', to: 'webUrl' }, + ]; expect( results.every((r, idx) => MIRRORED_FIELDS.every( - (field) => r[field] === statusEndpointFixture.importable_data[idx][field], + (field) => r[field.to] === statusEndpointFixture.importable_data[idx][field.from], ), ), ).toBe(true); }); - it('populates each result instance with status default to none', () => { - expect(results.every((r) => r.progress.status === STATUSES.NONE)).toBe(true); - }); - - it('populates each result instance with import_target defaulted to first available namespace', () => { - expect( - results.every( - (r) => r.import_target.target_namespace === availableNamespacesFixture[0].full_path, - ), - ).toBe(true); - }); - - it('starts polling when request completes', async () => { - const [statusPoller] = StatusPoller.mock.instances; - expect(statusPoller.startPolling).toHaveBeenCalled(); - }); - - it('requests validation status when request completes', async () => { - expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).not.toHaveBeenCalled(); - jest.runOnlyPendingTimers(); - expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalled(); + it('populates each result instance with empty status', () => { + expect(results.every((r) => r.progress === null)).toBe(true); }); }); @@ -223,6 +148,7 @@ describe('Bulk import resolvers', () => { `( 'properly passes GraphQL variable $variable as REST $queryParam query parameter', async ({ variable, queryParam, value }) => { + axiosMockAdapter.resetHistory(); await client.query({ query: bulkImportSourceGroupsQuery, variables: { [variable]: value }, @@ -237,275 +163,61 @@ describe('Bulk import resolvers', () => { }); describe('mutations', () => { - const GROUP_ID = 1; - beforeEach(() => { - client.writeQuery({ - query: bulkImportSourceGroupsQuery, - data: { - bulkImportSourceGroups: { - nodes: [ - { - __typename: clientTypenames.BulkImportSourceGroup, - id: GROUP_ID, - progress: { - id: `test-${GROUP_ID}`, - status: STATUSES.NONE, - }, - web_url: 'https://fake.host/1', - full_path: 'fake_group_1', - full_name: 'fake_name_1', - import_target: { - target_namespace: 'root', - new_name: 'group1', - }, - last_import_target: { - target_namespace: 'root', - new_name: 'group1', - }, - validation_errors: [], - }, - ], - pageInfo: { - page: 1, - perPage: 20, - total: 37, - totalPages: 2, - }, - }, - }, - }); + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); }); - describe('setImportTarget', () => { - it('updates group target namespace and name', async () => { - const NEW_TARGET_NAMESPACE = 'target'; - const NEW_NAME = 'new'; - - const { - data: { - setImportTarget: { - id: idInResponse, - import_target: { target_namespace: namespaceInResponse, new_name: newNameInResponse }, - }, - }, - } = await client.mutate({ - mutation: setImportTargetMutation, - variables: { - sourceGroupId: GROUP_ID, - targetNamespace: NEW_TARGET_NAMESPACE, - newName: NEW_NAME, - }, - }); - - expect(idInResponse).toBe(GROUP_ID); - expect(namespaceInResponse).toBe(NEW_TARGET_NAMESPACE); - expect(newNameInResponse).toBe(NEW_NAME); - }); - - it('invokes validation', async () => { - const NEW_TARGET_NAMESPACE = 'target'; - const NEW_NAME = 'new'; - + describe('importGroup', () => { + it('sets import status to CREATED when request completes', async () => { await client.mutate({ - mutation: setImportTargetMutation, + mutation: importGroupsMutation, variables: { - sourceGroupId: GROUP_ID, - targetNamespace: NEW_TARGET_NAMESPACE, - newName: NEW_NAME, + importRequests: [ + { + sourceGroupId: statusEndpointFixture.importable_data[0].id, + newName: 'test', + targetNamespace: 'root', + }, + ], }, }); - expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalledWith({ - fullPath: `${NEW_TARGET_NAMESPACE}/${NEW_NAME}`, - }); - }); - }); - - describe('importGroup', () => { - it('sets status to SCHEDULING when request initiates', async () => { - axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(() => new Promise(() => {})); - - client.mutate({ - mutation: importGroupsMutation, - variables: { sourceGroupIds: [GROUP_ID] }, - }); - await waitForPromises(); - - const { - bulkImportSourceGroups: { nodes: intermediateResults }, - } = client.readQuery({ - query: bulkImportSourceGroupsQuery, - }); - - expect(intermediateResults[0].progress.status).toBe(STATUSES.SCHEDULING); - }); - - describe('when request completes', () => { - let results; - - beforeEach(() => { - client - .watchQuery({ - query: bulkImportSourceGroupsQuery, - fetchPolicy: 'cache-only', - }) - .subscribe(({ data }) => { - results = data.bulkImportSourceGroups.nodes; - }); - }); - - it('sets import status to CREATED when request completes', async () => { - axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); - await client.mutate({ - mutation: importGroupsMutation, - variables: { sourceGroupIds: [GROUP_ID] }, - }); - await waitForPromises(); - - expect(results[0].progress.status).toBe(STATUSES.CREATED); - }); - - it('resets status to NONE if request fails', async () => { - axiosMockAdapter - .onPost(FAKE_ENDPOINTS.createBulkImport) - .reply(httpStatus.INTERNAL_SERVER_ERROR); - - client - .mutate({ - mutation: [importGroupsMutation], - variables: { sourceGroupIds: [GROUP_ID] }, - }) - .catch(() => {}); - await waitForPromises(); - - expect(results[0].progress.status).toBe(STATUSES.NONE); - }); - }); - - it('shows default error message when server error is not provided', async () => { - axiosMockAdapter - .onPost(FAKE_ENDPOINTS.createBulkImport) - .reply(httpStatus.INTERNAL_SERVER_ERROR); - - client - .mutate({ - mutation: importGroupsMutation, - variables: { sourceGroupIds: [GROUP_ID] }, - }) - .catch(() => {}); - await waitForPromises(); - - expect(createFlash).toHaveBeenCalledWith({ message: 'Importing the group failed' }); - }); - - it('shows provided error message when error is included in backend response', async () => { - const CUSTOM_MESSAGE = 'custom message'; - - axiosMockAdapter - .onPost(FAKE_ENDPOINTS.createBulkImport) - .reply(httpStatus.INTERNAL_SERVER_ERROR, { error: CUSTOM_MESSAGE }); - - client - .mutate({ - mutation: importGroupsMutation, - variables: { sourceGroupIds: [GROUP_ID] }, - }) - .catch(() => {}); - await waitForPromises(); - - expect(createFlash).toHaveBeenCalledWith({ message: CUSTOM_MESSAGE }); + await axios.waitForAll(); + expect(results[0].progress.status).toBe(STATUSES.CREATED); }); }); - it('setImportProgress updates group progress and sets import target', async () => { + it('updateImportStatus updates status', async () => { const NEW_STATUS = 'dummy'; - const FAKE_JOB_ID = 5; - const IMPORT_TARGET = { - __typename: 'ClientBulkImportTarget', - new_name: 'fake_name', - target_namespace: 'fake_target', - }; - const { - data: { - setImportProgress: { progress, last_import_target: lastImportTarget }, - }, - } = await client.mutate({ - mutation: setImportProgressMutation, + await client.mutate({ + mutation: importGroupsMutation, variables: { - sourceGroupId: GROUP_ID, - status: NEW_STATUS, - jobId: FAKE_JOB_ID, - importTarget: IMPORT_TARGET, + importRequests: [ + { + sourceGroupId: statusEndpointFixture.importable_data[0].id, + newName: 'test', + targetNamespace: 'root', + }, + ], }, }); + await axios.waitForAll(); + await waitForPromises(); - expect(lastImportTarget).toStrictEqual(IMPORT_TARGET); - - expect(progress).toStrictEqual({ - __typename: clientTypenames.BulkImportProgress, - id: FAKE_JOB_ID, - status: NEW_STATUS, - }); - }); + const { id } = results[0].progress; - it('updateImportStatus returns new status', async () => { - const NEW_STATUS = 'dummy'; - const FAKE_JOB_ID = 5; const { data: { updateImportStatus: statusInResponse }, } = await client.mutate({ mutation: updateImportStatusMutation, - variables: { id: FAKE_JOB_ID, status: NEW_STATUS }, + variables: { id, status: NEW_STATUS }, }); expect(statusInResponse).toStrictEqual({ __typename: clientTypenames.BulkImportProgress, - id: FAKE_JOB_ID, + id, status: NEW_STATUS, }); }); - - it('addValidationError adds error to group', async () => { - const FAKE_FIELD = 'some-field'; - const FAKE_MESSAGE = 'some-message'; - const { - data: { - addValidationError: { validation_errors: validationErrors }, - }, - } = await client.mutate({ - mutation: addValidationErrorMutation, - variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE }, - }); - - expect(validationErrors).toStrictEqual([ - { - __typename: clientTypenames.BulkImportValidationError, - field: FAKE_FIELD, - message: FAKE_MESSAGE, - }, - ]); - }); - - it('removeValidationError removes error from group', async () => { - const FAKE_FIELD = 'some-field'; - const FAKE_MESSAGE = 'some-message'; - - await client.mutate({ - mutation: addValidationErrorMutation, - variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE }, - }); - - const { - data: { - removeValidationError: { validation_errors: validationErrors }, - }, - } = await client.mutate({ - mutation: removeValidationErrorMutation, - variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD }, - }); - - expect(validationErrors).toStrictEqual([]); - }); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js index d1bd52693b6..5f6f9987a8f 100644 --- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -1,24 +1,24 @@ +import { STATUSES } from '~/import_entities/constants'; import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; export const generateFakeEntry = ({ id, status, ...rest }) => ({ __typename: clientTypenames.BulkImportSourceGroup, - web_url: `https://fake.host/${id}`, - full_path: `fake_group_${id}`, - full_name: `fake_name_${id}`, - import_target: { - target_namespace: 'root', - new_name: `group${id}`, - }, - last_import_target: { - target_namespace: 'root', - new_name: `last-group${id}`, + webUrl: `https://fake.host/${id}`, + fullPath: `fake_group_${id}`, + fullName: `fake_name_${id}`, + lastImportTarget: { + id, + targetNamespace: 'root', + newName: `group${id}`, }, id, - progress: { - id: `test-${id}`, - status, - }, - validation_errors: [], + progress: + status === STATUSES.NONE || status === STATUSES.PENDING + ? null + : { + id, + status, + }, ...rest, }); @@ -51,9 +51,9 @@ export const statusEndpointFixture = { ], }; -export const availableNamespacesFixture = [ - { id: 24, full_path: 'Commit451' }, - { id: 22, full_path: 'gitlab-org' }, - { id: 23, full_path: 'gnuwget' }, - { id: 25, full_path: 'jashkenas' }, -]; +export const availableNamespacesFixture = Object.freeze([ + { id: 24, fullPath: 'Commit451' }, + { id: 22, fullPath: 'gitlab-org' }, + { id: 23, fullPath: 'gnuwget' }, + { id: 25, fullPath: 'jashkenas' }, +]); diff --git a/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js new file mode 100644 index 00000000000..b44a2767ad8 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js @@ -0,0 +1,61 @@ +import { + KEY, + LocalStorageCache, +} from '~/import_entities/import_groups/graphql/services/local_storage_cache'; + +describe('Local storage cache', () => { + let cache; + let storage; + + beforeEach(() => { + storage = { + getItem: jest.fn(), + setItem: jest.fn(), + }; + + cache = new LocalStorageCache({ storage }); + }); + + describe('storage management', () => { + const IMPORT_URL = 'http://fake.url'; + + it('loads state from storage on creation', () => { + expect(storage.getItem).toHaveBeenCalledWith(KEY); + }); + + it('saves to storage when set is called', () => { + const STORAGE_CONTENT = { fake: 'content ' }; + cache.set(IMPORT_URL, STORAGE_CONTENT); + expect(storage.setItem).toHaveBeenCalledWith( + KEY, + JSON.stringify({ [IMPORT_URL]: STORAGE_CONTENT }), + ); + }); + + it('updates status by job id', () => { + const CHANGED_STATUS = 'changed'; + const JOB_ID = 2; + + cache.set(IMPORT_URL, { + progress: { + id: JOB_ID, + status: 'original', + }, + }); + + cache.updateStatusByJobId(JOB_ID, CHANGED_STATUS); + + expect(storage.setItem).toHaveBeenCalledWith( + KEY, + JSON.stringify({ + [IMPORT_URL]: { + progress: { + id: JOB_ID, + status: CHANGED_STATUS, + }, + }, + }), + ); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js deleted file mode 100644 index f06babcb149..00000000000 --- a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { - KEY, - SourceGroupsManager, -} from '~/import_entities/import_groups/graphql/services/source_groups_manager'; - -const FAKE_SOURCE_URL = 'http://demo.host'; - -describe('SourceGroupsManager', () => { - let manager; - let storage; - - beforeEach(() => { - storage = { - getItem: jest.fn(), - setItem: jest.fn(), - }; - - manager = new SourceGroupsManager({ storage, sourceUrl: FAKE_SOURCE_URL }); - }); - - describe('storage management', () => { - const IMPORT_ID = 1; - const IMPORT_TARGET = { new_name: 'demo', target_namespace: 'foo' }; - const STATUS = 'FAKE_STATUS'; - const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS }; - - it('loads state from storage on creation', () => { - expect(storage.getItem).toHaveBeenCalledWith(KEY); - }); - - it('saves to storage when createImportState is called', () => { - const FAKE_STATUS = 'fake;'; - manager.createImportState(IMPORT_ID, { status: FAKE_STATUS, groups: [FAKE_GROUP] }); - const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]); - expect(Object.values(storedObject)[0]).toStrictEqual({ - status: FAKE_STATUS, - groups: [ - { - id: FAKE_GROUP.id, - importTarget: IMPORT_TARGET, - }, - ], - }); - }); - - it('updates storage when previous state is available', () => { - const CHANGED_STATUS = 'changed'; - - manager.createImportState(IMPORT_ID, { status: STATUS, groups: [FAKE_GROUP] }); - - manager.updateImportProgress(IMPORT_ID, CHANGED_STATUS); - const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]); - expect(Object.values(storedObject)[0]).toStrictEqual({ - status: CHANGED_STATUS, - groups: [ - { - id: FAKE_GROUP.id, - importTarget: IMPORT_TARGET, - }, - ], - }); - }); - }); -}); diff --git a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js deleted file mode 100644 index 9c47647c430..00000000000 --- a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js +++ /dev/null @@ -1,102 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; -import { STATUSES } from '~/import_entities/constants'; -import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; -import axios from '~/lib/utils/axios_utils'; -import Poll from '~/lib/utils/poll'; - -jest.mock('visibilityjs'); -jest.mock('~/flash'); -jest.mock('~/lib/utils/poll'); -jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manager', () => ({ - SourceGroupsManager: jest.fn().mockImplementation(function mock() { - this.setImportStatus = jest.fn(); - this.findByImportId = jest.fn(); - }), -})); - -const FAKE_POLL_PATH = '/fake/poll/path'; - -describe('Bulk import status poller', () => { - let poller; - let mockAdapter; - let updateImportStatus; - - const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH); - - beforeEach(() => { - mockAdapter = new MockAdapter(axios); - mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {}); - updateImportStatus = jest.fn(); - poller = new StatusPoller({ updateImportStatus, pollPath: FAKE_POLL_PATH }); - }); - - it('creates poller with proper config', () => { - expect(Poll.mock.calls).toHaveLength(1); - const [[pollConfig]] = Poll.mock.calls; - expect(typeof pollConfig.method).toBe('string'); - - const pollOperation = pollConfig.resource[pollConfig.method]; - expect(typeof pollOperation).toBe('function'); - }); - - it('invokes axios when polling is performed', async () => { - const [[pollConfig]] = Poll.mock.calls; - const pollOperation = pollConfig.resource[pollConfig.method]; - expect(getPollHistory()).toHaveLength(0); - - pollOperation(); - await axios.waitForAll(); - - expect(getPollHistory()).toHaveLength(1); - }); - - it('subscribes to visibility changes', () => { - expect(Visibility.change).toHaveBeenCalled(); - }); - - it.each` - isHidden | action - ${true} | ${'stop'} - ${false} | ${'restart'} - `('$action polling when hidden is $isHidden', ({ action, isHidden }) => { - const [pollInstance] = Poll.mock.instances; - const [[changeHandler]] = Visibility.change.mock.calls; - Visibility.hidden.mockReturnValue(isHidden); - expect(pollInstance[action]).not.toHaveBeenCalled(); - - changeHandler(); - - expect(pollInstance[action]).toHaveBeenCalled(); - }); - - it('does not perform polling when constructed', async () => { - await axios.waitForAll(); - - expect(getPollHistory()).toHaveLength(0); - }); - - it('immediately start polling when requested', async () => { - const [pollInstance] = Poll.mock.instances; - - poller.startPolling(); - - expect(pollInstance.makeRequest).toHaveBeenCalled(); - }); - - it('when error occurs shows flash with error', () => { - const [[pollConfig]] = Poll.mock.calls; - pollConfig.errorCallback(); - expect(createFlash).toHaveBeenCalled(); - }); - - it('when success response arrives updates relevant group status', () => { - const FAKE_ID = 5; - const [[pollConfig]] = Poll.mock.calls; - const FAKE_RESPONSE = { id: FAKE_ID, status_name: STATUSES.FINISHED }; - pollConfig.successCallback({ data: [FAKE_RESPONSE] }); - - expect(updateImportStatus).toHaveBeenCalledWith(FAKE_RESPONSE); - }); -}); diff --git a/spec/frontend/import_entities/import_groups/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/services/status_poller_spec.js new file mode 100644 index 00000000000..01f976562c6 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/services/status_poller_spec.js @@ -0,0 +1,97 @@ +import MockAdapter from 'axios-mock-adapter'; +import Visibility from 'visibilityjs'; +import createFlash from '~/flash'; +import { STATUSES } from '~/import_entities/constants'; +import { StatusPoller } from '~/import_entities/import_groups/services/status_poller'; +import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; + +jest.mock('visibilityjs'); +jest.mock('~/flash'); +jest.mock('~/lib/utils/poll'); + +const FAKE_POLL_PATH = '/fake/poll/path'; + +describe('Bulk import status poller', () => { + let poller; + let mockAdapter; + let updateImportStatus; + + const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH); + + beforeEach(() => { + mockAdapter = new MockAdapter(axios); + mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {}); + updateImportStatus = jest.fn(); + poller = new StatusPoller({ updateImportStatus, pollPath: FAKE_POLL_PATH }); + }); + + it('creates poller with proper config', () => { + expect(Poll.mock.calls).toHaveLength(1); + const [[pollConfig]] = Poll.mock.calls; + expect(typeof pollConfig.method).toBe('string'); + + const pollOperation = pollConfig.resource[pollConfig.method]; + expect(typeof pollOperation).toBe('function'); + }); + + it('invokes axios when polling is performed', async () => { + const [[pollConfig]] = Poll.mock.calls; + const pollOperation = pollConfig.resource[pollConfig.method]; + expect(getPollHistory()).toHaveLength(0); + + pollOperation(); + await axios.waitForAll(); + + expect(getPollHistory()).toHaveLength(1); + }); + + it('subscribes to visibility changes', () => { + expect(Visibility.change).toHaveBeenCalled(); + }); + + it.each` + isHidden | action + ${true} | ${'stop'} + ${false} | ${'restart'} + `('$action polling when hidden is $isHidden', ({ action, isHidden }) => { + const [pollInstance] = Poll.mock.instances; + const [[changeHandler]] = Visibility.change.mock.calls; + Visibility.hidden.mockReturnValue(isHidden); + expect(pollInstance[action]).not.toHaveBeenCalled(); + + changeHandler(); + + expect(pollInstance[action]).toHaveBeenCalled(); + }); + + it('does not perform polling when constructed', async () => { + await axios.waitForAll(); + + expect(getPollHistory()).toHaveLength(0); + }); + + it('immediately start polling when requested', async () => { + const [pollInstance] = Poll.mock.instances; + + poller.startPolling(); + await Promise.resolve(); + + expect(pollInstance.makeRequest).toHaveBeenCalled(); + }); + + it('when error occurs shows flash with error', () => { + const [[pollConfig]] = Poll.mock.calls; + pollConfig.errorCallback(); + expect(createFlash).toHaveBeenCalled(); + }); + + it('when success response arrives updates relevant group status', () => { + const FAKE_ID = 5; + const [[pollConfig]] = Poll.mock.calls; + const FAKE_RESPONSE = { id: FAKE_ID, status_name: STATUSES.FINISHED }; + pollConfig.successCallback({ data: [FAKE_RESPONSE] }); + + expect(updateImportStatus).toHaveBeenCalledWith(FAKE_RESPONSE); + }); +}); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 8d4ccab2a40..48545ffd2d6 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -78,6 +78,7 @@ describe('Incidents List', () => { authorUsernameQuery: '', assigneeUsernameQuery: '', slaFeatureAvailable: true, + canCreateIncident: true, ...provide, }, stubs: { @@ -105,21 +106,23 @@ describe('Incidents List', () => { describe('empty state', () => { const { - emptyState: { title, emptyClosedTabTitle, description }, + emptyState: { title, emptyClosedTabTitle, description, cannotCreateIncidentDescription }, } = I18N; it.each` - statusFilter | all | closed | expectedTitle | expectedDescription - ${'all'} | ${2} | ${1} | ${title} | ${description} - ${'open'} | ${2} | ${0} | ${title} | ${description} - ${'closed'} | ${0} | ${0} | ${title} | ${description} - ${'closed'} | ${2} | ${0} | ${emptyClosedTabTitle} | ${undefined} + statusFilter | all | closed | expectedTitle | canCreateIncident | expectedDescription + ${'all'} | ${2} | ${1} | ${title} | ${true} | ${description} + ${'open'} | ${2} | ${0} | ${title} | ${true} | ${description} + ${'closed'} | ${0} | ${0} | ${title} | ${true} | ${description} + ${'closed'} | ${2} | ${0} | ${emptyClosedTabTitle} | ${true} | ${undefined} + ${'all'} | ${2} | ${1} | ${title} | ${false} | ${cannotCreateIncidentDescription} `( `when active tab is $statusFilter and there are $all incidents in total and $closed closed incidents, the empty state has title: $expectedTitle and description: $expectedDescription`, - ({ statusFilter, all, closed, expectedTitle, expectedDescription }) => { + ({ statusFilter, all, closed, expectedTitle, expectedDescription, canCreateIncident }) => { mountComponent({ data: { incidents: { list: [] }, incidentsCount: { all, closed }, statusFilter }, + provide: { canCreateIncident }, loading: false, }); expect(findEmptyState().exists()).toBe(true); @@ -219,6 +222,15 @@ describe('Incidents List', () => { expect(findCreateIncidentBtn().exists()).toBe(false); }); + it("doesn't show the button when user does not have incident creation permissions", () => { + mountComponent({ + data: { incidents: { list: mockIncidents }, incidentsCount: {} }, + provide: { canCreateIncident: false }, + loading: false, + }); + expect(findCreateIncidentBtn().exists()).toBe(false); + }); + it('should track create new incident button', async () => { findCreateIncidentBtn().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index da8a2f41c1b..bf044e388ea 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -35,136 +35,145 @@ describe('DynamicField', () => { const findGlFormTextarea = () => wrapper.findComponent(GlFormTextarea); describe('template', () => { - describe.each([ - [true, 'disabled', 'readonly'], - [false, undefined, undefined], - ])('dynamic field, when isInheriting = `%p`', (isInheriting, disabled, readonly) => { - describe('type is checkbox', () => { - beforeEach(() => { - createComponent( - { - type: 'checkbox', - }, - isInheriting, - ); - }); + describe.each` + isInheriting | disabled | readonly | checkboxLabel + ${true} | ${'disabled'} | ${'readonly'} | ${undefined} + ${false} | ${undefined} | ${undefined} | ${'Custom checkbox label'} + `( + 'dynamic field, when isInheriting = `%p`', + ({ isInheriting, disabled, readonly, checkboxLabel }) => { + describe('type is checkbox', () => { + beforeEach(() => { + createComponent( + { + type: 'checkbox', + checkboxLabel, + }, + isInheriting, + ); + }); - it(`renders GlFormCheckbox, which ${isInheriting ? 'is' : 'is not'} disabled`, () => { - expect(findGlFormCheckbox().exists()).toBe(true); - expect(findGlFormCheckbox().find('[type=checkbox]').attributes('disabled')).toBe( - disabled, - ); - }); + it(`renders GlFormCheckbox, which ${isInheriting ? 'is' : 'is not'} disabled`, () => { + expect(findGlFormCheckbox().exists()).toBe(true); + expect(findGlFormCheckbox().find('[type=checkbox]').attributes('disabled')).toBe( + disabled, + ); + }); - it('does not render other types of input', () => { - expect(findGlFormSelect().exists()).toBe(false); - expect(findGlFormTextarea().exists()).toBe(false); - expect(findGlFormInput().exists()).toBe(false); - }); - }); + it(`renders GlFormCheckbox with correct text content when checkboxLabel is ${checkboxLabel}`, () => { + expect(findGlFormCheckbox().text()).toBe(checkboxLabel ?? defaultProps.title); + }); - describe('type is select', () => { - beforeEach(() => { - createComponent( - { - type: 'select', - choices: [ - ['all', 'All details'], - ['standard', 'Standard'], - ], - }, - isInheriting, - ); + it('does not render other types of input', () => { + expect(findGlFormSelect().exists()).toBe(false); + expect(findGlFormTextarea().exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); + }); }); - it(`renders GlFormSelect, which ${isInheriting ? 'is' : 'is not'} disabled`, () => { - expect(findGlFormSelect().exists()).toBe(true); - expect(findGlFormSelect().findAll('option')).toHaveLength(2); - expect(findGlFormSelect().find('select').attributes('disabled')).toBe(disabled); - }); + describe('type is select', () => { + beforeEach(() => { + createComponent( + { + type: 'select', + choices: [ + ['all', 'All details'], + ['standard', 'Standard'], + ], + }, + isInheriting, + ); + }); - it('does not render other types of input', () => { - expect(findGlFormCheckbox().exists()).toBe(false); - expect(findGlFormTextarea().exists()).toBe(false); - expect(findGlFormInput().exists()).toBe(false); - }); - }); + it(`renders GlFormSelect, which ${isInheriting ? 'is' : 'is not'} disabled`, () => { + expect(findGlFormSelect().exists()).toBe(true); + expect(findGlFormSelect().findAll('option')).toHaveLength(2); + expect(findGlFormSelect().find('select').attributes('disabled')).toBe(disabled); + }); - describe('type is textarea', () => { - beforeEach(() => { - createComponent( - { - type: 'textarea', - }, - isInheriting, - ); + it('does not render other types of input', () => { + expect(findGlFormCheckbox().exists()).toBe(false); + expect(findGlFormTextarea().exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); + }); }); - it(`renders GlFormTextarea, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { - expect(findGlFormTextarea().exists()).toBe(true); - expect(findGlFormTextarea().find('textarea').attributes('readonly')).toBe(readonly); - }); + describe('type is textarea', () => { + beforeEach(() => { + createComponent( + { + type: 'textarea', + }, + isInheriting, + ); + }); - it('does not render other types of input', () => { - expect(findGlFormCheckbox().exists()).toBe(false); - expect(findGlFormSelect().exists()).toBe(false); - expect(findGlFormInput().exists()).toBe(false); - }); - }); + it(`renders GlFormTextarea, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { + expect(findGlFormTextarea().exists()).toBe(true); + expect(findGlFormTextarea().find('textarea').attributes('readonly')).toBe(readonly); + }); - describe('type is password', () => { - beforeEach(() => { - createComponent( - { - type: 'password', - }, - isInheriting, - ); + it('does not render other types of input', () => { + expect(findGlFormCheckbox().exists()).toBe(false); + expect(findGlFormSelect().exists()).toBe(false); + expect(findGlFormInput().exists()).toBe(false); + }); }); - it(`renders GlFormInput, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { - expect(findGlFormInput().exists()).toBe(true); - expect(findGlFormInput().attributes('type')).toBe('password'); - expect(findGlFormInput().attributes('readonly')).toBe(readonly); - }); + describe('type is password', () => { + beforeEach(() => { + createComponent( + { + type: 'password', + }, + isInheriting, + ); + }); - it('does not render other types of input', () => { - expect(findGlFormCheckbox().exists()).toBe(false); - expect(findGlFormSelect().exists()).toBe(false); - expect(findGlFormTextarea().exists()).toBe(false); - }); - }); + it(`renders GlFormInput, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { + expect(findGlFormInput().exists()).toBe(true); + expect(findGlFormInput().attributes('type')).toBe('password'); + expect(findGlFormInput().attributes('readonly')).toBe(readonly); + }); - describe('type is text', () => { - beforeEach(() => { - createComponent( - { - type: 'text', - required: true, - }, - isInheriting, - ); + it('does not render other types of input', () => { + expect(findGlFormCheckbox().exists()).toBe(false); + expect(findGlFormSelect().exists()).toBe(false); + expect(findGlFormTextarea().exists()).toBe(false); + }); }); - it(`renders GlFormInput, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { - expect(findGlFormInput().exists()).toBe(true); - expect(findGlFormInput().attributes()).toMatchObject({ - type: 'text', - id: 'service_project_url', - name: 'service[project_url]', - placeholder: defaultProps.placeholder, - required: 'required', + describe('type is text', () => { + beforeEach(() => { + createComponent( + { + type: 'text', + required: true, + }, + isInheriting, + ); }); - expect(findGlFormInput().attributes('readonly')).toBe(readonly); - }); - it('does not render other types of input', () => { - expect(findGlFormCheckbox().exists()).toBe(false); - expect(findGlFormSelect().exists()).toBe(false); - expect(findGlFormTextarea().exists()).toBe(false); + it(`renders GlFormInput, which ${isInheriting ? 'is' : 'is not'} readonly`, () => { + expect(findGlFormInput().exists()).toBe(true); + expect(findGlFormInput().attributes()).toMatchObject({ + type: 'text', + id: 'service_project_url', + name: 'service[project_url]', + placeholder: defaultProps.placeholder, + required: 'required', + }); + expect(findGlFormInput().attributes('readonly')).toBe(readonly); + }); + + it('does not render other types of input', () => { + expect(findGlFormCheckbox().exists()).toBe(false); + expect(findGlFormSelect().exists()).toBe(false); + expect(findGlFormTextarea().exists()).toBe(false); + }); }); - }); - }); + }, + ); describe('help text', () => { it('renders description with help text', () => { diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 119afbfecfe..3a664b652ac 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -1,7 +1,10 @@ import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { GET_JIRA_ISSUE_TYPES_EVENT } from '~/integrations/constants'; +import { + GET_JIRA_ISSUE_TYPES_EVENT, + VALIDATE_INTEGRATION_FORM_EVENT, +} from '~/integrations/constants'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import eventHub from '~/integrations/edit/event_hub'; import { createStore } from '~/integrations/edit/store'; @@ -17,12 +20,17 @@ describe('JiraIssuesFields', () => { upgradePlanPath: 'https://gitlab.com', }; - const createComponent = ({ isInheriting = false, props, ...options } = {}) => { + const createComponent = ({ + isInheriting = false, + mountFn = mountExtended, + props, + ...options + } = {}) => { store = createStore({ defaultState: isInheriting ? {} : undefined, }); - wrapper = mountExtended(JiraIssuesFields, { + wrapper = mountFn(JiraIssuesFields, { propsData: { ...defaultProps, ...props }, store, stubs: ['jira-issue-creation-vulnerabilities'], @@ -38,12 +46,19 @@ describe('JiraIssuesFields', () => { const findEnableCheckboxDisabled = () => findEnableCheckbox().find('[type=checkbox]').attributes('disabled'); const findProjectKey = () => wrapper.findComponent(GlFormInput); + const findProjectKeyFormGroup = () => wrapper.findByTestId('project-key-form-group'); const findPremiumUpgradeCTA = () => wrapper.findByTestId('premium-upgrade-cta'); const findUltimateUpgradeCTA = () => wrapper.findByTestId('ultimate-upgrade-cta'); const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities'); + const findConflictWarning = () => wrapper.findByTestId('conflict-warning-text'); const setEnableCheckbox = async (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); + const assertProjectKeyState = (expectedStateValue) => { + expect(findProjectKey().attributes('state')).toBe(expectedStateValue); + expect(findProjectKeyFormGroup().attributes('state')).toBe(expectedStateValue); + }; + describe('template', () => { describe.each` showJiraIssuesIntegration | showJiraVulnerabilitiesIntegration @@ -151,19 +166,18 @@ describe('JiraIssuesFields', () => { }); describe('GitLab issues warning', () => { - const expectedText = 'Consider disabling GitLab issues'; - - it('contains warning when GitLab issues is enabled', () => { - createComponent(); - - expect(wrapper.text()).toContain(expectedText); - }); - - it('does not contain warning when GitLab issues is disabled', () => { - createComponent({ props: { gitlabIssuesEnabled: false } }); - - expect(wrapper.text()).not.toContain(expectedText); - }); + it.each` + gitlabIssuesEnabled | scenario + ${true} | ${'displays conflict warning'} + ${false} | ${'does not display conflict warning'} + `( + '$scenario when `gitlabIssuesEnabled` is `$gitlabIssuesEnabled`', + ({ gitlabIssuesEnabled }) => { + createComponent({ props: { gitlabIssuesEnabled } }); + + expect(findConflictWarning().exists()).toBe(gitlabIssuesEnabled); + }, + ); }); describe('Vulnerabilities creation', () => { @@ -211,5 +225,44 @@ describe('JiraIssuesFields', () => { expect(eventHubEmitSpy).toHaveBeenCalledWith(GET_JIRA_ISSUE_TYPES_EVENT); }); }); + + describe('Project key input field', () => { + beforeEach(() => { + createComponent({ + props: { + initialProjectKey: '', + initialEnableJiraIssues: true, + }, + mountFn: shallowMountExtended, + }); + }); + + it('sets Project Key `state` attribute to `true` by default', () => { + assertProjectKeyState('true'); + }); + + describe('when event hub recieves `VALIDATE_INTEGRATION_FORM_EVENT` event', () => { + describe('with no project key', () => { + it('sets Project Key `state` attribute to `undefined`', async () => { + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + await wrapper.vm.$nextTick(); + + assertProjectKeyState(undefined); + }); + }); + + describe('when project key is set', () => { + it('sets Project Key `state` attribute to `true`', async () => { + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + + // set the project key + await findProjectKey().vm.$emit('input', 'AB'); + await wrapper.vm.$nextTick(); + + assertProjectKeyState('true'); + }); + }); + }); + }); }); }); diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js index f8f3f0fd318..c35d178e518 100644 --- a/spec/frontend/integrations/integration_settings_form_spec.js +++ b/spec/frontend/integrations/integration_settings_form_spec.js @@ -1,27 +1,38 @@ import MockAdaptor from 'axios-mock-adapter'; import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import eventHub from '~/integrations/edit/event_hub'; import axios from '~/lib/utils/axios_utils'; import toast from '~/vue_shared/plugins/global_toast'; +import { + I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + I18N_SUCCESSFUL_CONNECTION_MESSAGE, + I18N_DEFAULT_ERROR_MESSAGE, + GET_JIRA_ISSUE_TYPES_EVENT, + TOGGLE_INTEGRATION_EVENT, + TEST_INTEGRATION_EVENT, + SAVE_INTEGRATION_EVENT, +} from '~/integrations/constants'; +import waitForPromises from 'helpers/wait_for_promises'; jest.mock('~/vue_shared/plugins/global_toast'); +jest.mock('lodash/delay', () => (callback) => callback()); + +const FIXTURE = 'services/edit_service.html'; describe('IntegrationSettingsForm', () => { - const FIXTURE = 'services/edit_service.html'; + let integrationSettingsForm; + + const mockStoreDispatch = () => jest.spyOn(integrationSettingsForm.vue.$store, 'dispatch'); beforeEach(() => { loadFixtures(FIXTURE); + + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + integrationSettingsForm.init(); }); describe('constructor', () => { - let integrationSettingsForm; - - beforeEach(() => { - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - jest.spyOn(integrationSettingsForm, 'init').mockImplementation(() => {}); - }); - it('should initialize form element refs on class object', () => { - // Form Reference expect(integrationSettingsForm.$form).toBeDefined(); expect(integrationSettingsForm.$form.nodeName).toBe('FORM'); expect(integrationSettingsForm.formActive).toBeDefined(); @@ -32,180 +43,206 @@ describe('IntegrationSettingsForm', () => { }); }); - describe('toggleServiceState', () => { - let integrationSettingsForm; + describe('event handling', () => { + let mockAxios; beforeEach(() => { - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - }); - - it('should remove `novalidate` attribute to form when called with `true`', () => { - integrationSettingsForm.formActive = true; - integrationSettingsForm.toggleServiceState(); - - expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null); - }); - - it('should set `novalidate` attribute to form when called with `false`', () => { - integrationSettingsForm.formActive = false; - integrationSettingsForm.toggleServiceState(); - - expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBeDefined(); - }); - }); - - describe('testSettings', () => { - let integrationSettingsForm; - let formData; - let mock; - - beforeEach(() => { - mock = new MockAdaptor(axios); - + mockAxios = new MockAdaptor(axios); jest.spyOn(axios, 'put'); - - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); - - formData = new FormData(integrationSettingsForm.$form); }); afterEach(() => { - mock.restore(); + mockAxios.restore(); + eventHub.dispose(); // clear event hub handlers }); - it('should make an ajax request with provided `formData`', async () => { - await integrationSettingsForm.testSettings(formData); + describe('when event hub receives `TOGGLE_INTEGRATION_EVENT`', () => { + it('should remove `novalidate` attribute to form when called with `true`', () => { + eventHub.$emit(TOGGLE_INTEGRATION_EVENT, true); - expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData); - }); + expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null); + }); - it('should show success message if test is successful', async () => { - jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); + it('should set `novalidate` attribute to form when called with `false`', () => { + eventHub.$emit(TOGGLE_INTEGRATION_EVENT, false); - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: false, + expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe('novalidate'); }); + }); - await integrationSettingsForm.testSettings(formData); + describe('when event hub receives `TEST_INTEGRATION_EVENT`', () => { + describe('when form is valid', () => { + beforeEach(() => { + jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true); + }); - expect(toast).toHaveBeenCalledWith('Connection successful.'); - }); + it('should make an ajax request with provided `formData`', async () => { + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - it('should show error message if ajax request responds with test error', async () => { - const errorMessage = 'Test failed.'; - const serviceResponse = 'some error'; + expect(axios.put).toHaveBeenCalledWith( + integrationSettingsForm.testEndPoint, + new FormData(integrationSettingsForm.$form), + ); + }); - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: true, - message: errorMessage, - service_response: serviceResponse, - test_failed: false, - }); + it('should show success message if test is successful', async () => { + jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); - await integrationSettingsForm.testSettings(formData); + mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: false, + }); - expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`); - }); + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - it('should show error message if ajax request failed', async () => { - const errorMessage = 'Something went wrong on our end.'; + expect(toast).toHaveBeenCalledWith(I18N_SUCCESSFUL_CONNECTION_MESSAGE); + }); - mock.onPut(integrationSettingsForm.testEndPoint).networkError(); + it('should show error message if ajax request responds with test error', async () => { + const errorMessage = 'Test failed.'; + const serviceResponse = 'some error'; - await integrationSettingsForm.testSettings(formData); + mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: true, + message: errorMessage, + service_response: serviceResponse, + test_failed: false, + }); - expect(toast).toHaveBeenCalledWith(errorMessage); - }); + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - it('should always dispatch `setIsTesting` with `false` once request is completed', async () => { - const dispatchSpy = jest.fn(); + expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`); + }); - mock.onPut(integrationSettingsForm.testEndPoint).networkError(); + it('should show error message if ajax request failed', async () => { + mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - await integrationSettingsForm.testSettings(formData); + expect(toast).toHaveBeenCalledWith(I18N_DEFAULT_ERROR_MESSAGE); + }); - expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); - }); - }); + it('should always dispatch `setIsTesting` with `false` once request is completed', async () => { + const dispatchSpy = mockStoreDispatch(); + mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - describe('getJiraIssueTypes', () => { - let integrationSettingsForm; - let formData; - let mock; + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - beforeEach(() => { - mock = new MockAdaptor(axios); + expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); + }); + }); - jest.spyOn(axios, 'put'); + describe('when form is invalid', () => { + beforeEach(() => { + jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false); + jest.spyOn(integrationSettingsForm, 'testSettings'); + }); - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); + it('should dispatch `setIsTesting` with `false` and not call `testSettings`', async () => { + const dispatchSpy = mockStoreDispatch(); - formData = new FormData(integrationSettingsForm.$form); - }); + eventHub.$emit(TEST_INTEGRATION_EVENT); + await waitForPromises(); - afterEach(() => { - mock.restore(); + expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); + expect(integrationSettingsForm.testSettings).not.toHaveBeenCalled(); + }); + }); }); - it('should always dispatch `requestJiraIssueTypes`', async () => { - const dispatchSpy = jest.fn(); - - mock.onPut(integrationSettingsForm.testEndPoint).networkError(); + describe('when event hub receives `GET_JIRA_ISSUE_TYPES_EVENT`', () => { + it('should always dispatch `requestJiraIssueTypes`', () => { + const dispatchSpy = mockStoreDispatch(); + mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError(); - integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - await integrationSettingsForm.getJiraIssueTypes(); + expect(dispatchSpy).toHaveBeenCalledWith('requestJiraIssueTypes'); + }); - expect(dispatchSpy).toHaveBeenCalledWith('requestJiraIssueTypes'); - }); + it('should make an ajax request with provided `formData`', () => { + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - it('should make an ajax request with provided `formData`', async () => { - await integrationSettingsForm.getJiraIssueTypes(formData); + expect(axios.put).toHaveBeenCalledWith( + integrationSettingsForm.testEndPoint, + new FormData(integrationSettingsForm.$form), + ); + }); - expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData); - }); + it('should dispatch `receiveJiraIssueTypesSuccess` with the correct payload if ajax request is successful', async () => { + const dispatchSpy = mockStoreDispatch(); + const mockData = ['ISSUE', 'EPIC']; + mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: false, + issuetypes: mockData, + }); - it('should dispatch `receiveJiraIssueTypesSuccess` with the correct payload if ajax request is successful', async () => { - const mockData = ['ISSUE', 'EPIC']; - const dispatchSpy = jest.fn(); + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); + await waitForPromises(); - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: false, - issuetypes: mockData, + expect(dispatchSpy).toHaveBeenCalledWith('receiveJiraIssueTypesSuccess', mockData); }); - integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; - - await integrationSettingsForm.getJiraIssueTypes(formData); + it.each(['Custom error message here', undefined])( + 'should dispatch "receiveJiraIssueTypesError" with a message if the backend responds with error', + async (responseErrorMessage) => { + const dispatchSpy = mockStoreDispatch(); + + const expectedErrorMessage = + responseErrorMessage || I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE; + mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, { + error: true, + message: responseErrorMessage, + }); + + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith( + 'receiveJiraIssueTypesError', + expectedErrorMessage, + ); + }, + ); + }); + + describe('when event hub receives `SAVE_INTEGRATION_EVENT`', () => { + describe('when form is valid', () => { + beforeEach(() => { + jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true); + jest.spyOn(integrationSettingsForm.$form, 'submit'); + }); - expect(dispatchSpy).toHaveBeenCalledWith('receiveJiraIssueTypesSuccess', mockData); - }); + it('should submit the form', async () => { + eventHub.$emit(SAVE_INTEGRATION_EVENT); + await waitForPromises(); - it.each(['something went wrong', undefined])( - 'should dispatch "receiveJiraIssueTypesError" with a message if the backend responds with error', - async (responseErrorMessage) => { - const defaultErrorMessage = 'Connection failed. Please check your settings.'; - const expectedErrorMessage = responseErrorMessage || defaultErrorMessage; - const dispatchSpy = jest.fn(); + expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); + expect(integrationSettingsForm.$form.submit).toHaveBeenCalledTimes(1); + }); + }); - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: true, - message: responseErrorMessage, + describe('when form is invalid', () => { + beforeEach(() => { + jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false); + jest.spyOn(integrationSettingsForm.$form, 'submit'); }); - integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; + it('should dispatch `setIsSaving` with `false` and not submit form', async () => { + const dispatchSpy = mockStoreDispatch(); - await integrationSettingsForm.getJiraIssueTypes(formData); + eventHub.$emit(SAVE_INTEGRATION_EVENT); - expect(dispatchSpy).toHaveBeenCalledWith( - 'receiveJiraIssueTypesError', - expectedErrorMessage, - ); - }, - ); + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('setIsSaving', false); + expect(integrationSettingsForm.$form.submit).not.toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/invite_members/components/confetti_spec.js b/spec/frontend/invite_members/components/confetti_spec.js new file mode 100644 index 00000000000..2f361f1dc1e --- /dev/null +++ b/spec/frontend/invite_members/components/confetti_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import confetti from 'canvas-confetti'; +import Confetti from '~/invite_members/components/confetti.vue'; + +jest.mock('canvas-confetti', () => ({ + create: jest.fn(), +})); + +let wrapper; + +const createComponent = () => { + wrapper = shallowMount(Confetti); +}; + +afterEach(() => { + wrapper.destroy(); +}); + +describe('Confetti', () => { + it('initiates confetti', () => { + const basicCannon = jest.spyOn(Confetti.methods, 'basicCannon').mockImplementation(() => {}); + + createComponent(); + + expect(confetti.create).toHaveBeenCalled(); + expect(basicCannon).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 8c3c549a5eb..5be79004640 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -15,17 +15,34 @@ import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; +import ModalConfetti from '~/invite_members/components/confetti.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; -import { INVITE_MEMBERS_IN_COMMENT, MEMBER_AREAS_OF_FOCUS } from '~/invite_members/constants'; +import { + INVITE_MEMBERS_IN_COMMENT, + MEMBER_AREAS_OF_FOCUS, + INVITE_MEMBERS_FOR_TASK, + CANCEL_BUTTON_TEXT, + INVITE_BUTTON_TEXT, + MEMBERS_MODAL_CELEBRATE_INTRO, + MEMBERS_MODAL_CELEBRATE_TITLE, + MEMBERS_MODAL_DEFAULT_TITLE, + MEMBERS_PLACEHOLDER, + MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, +} from '~/invite_members/constants'; import eventHub from '~/invite_members/event_hub'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; +import { getParameterValues } from '~/lib/utils/url_utility'; import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; let wrapper; let mock; jest.mock('~/experimentation/experiment_tracking'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + getParameterValues: jest.fn(() => []), +})); const id = '1'; const name = 'test name'; @@ -40,6 +57,15 @@ const areasOfFocusOptions = [ { text: 'area1', value: 'area1' }, { text: 'area2', value: 'area2' }, ]; +const tasksToBeDoneOptions = [ + { text: 'First task', value: 'first' }, + { text: 'Second task', value: 'second' }, +]; +const newProjectPath = 'projects/new'; +const projects = [ + { text: 'First project', value: '1' }, + { text: 'Second project', value: '2' }, +]; const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; @@ -56,9 +82,13 @@ const user4 = { avatar_url: '', }; const sharedGroup = { id: '981' }; +const GlEmoji = { template: '' }; const createComponent = (data = {}, props = {}) => { wrapper = shallowMountExtended(InviteMembersModal, { + provide: { + newProjectPath, + }, propsData: { id, name, @@ -68,6 +98,8 @@ const createComponent = (data = {}, props = {}) => { areasOfFocusOptions, defaultAccessLevel, noSelectionAreasOfFocus, + tasksToBeDoneOptions, + projects, helpLink, ...props, }, @@ -81,6 +113,7 @@ const createComponent = (data = {}, props = {}) => { }), GlDropdown: true, GlDropdownItem: true, + GlEmoji, GlSprintf, GlFormGroup: stubComponent(GlFormGroup, { props: ['state', 'invalidFeedback', 'description'], @@ -131,6 +164,11 @@ describe('InviteMembersModal', () => { const membersFormGroupDescription = () => findMembersFormGroup().props('description'); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup); + const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done'); + const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks'); + const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select'); + const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert'); + const findCelebrationEmoji = () => wrapper.findComponent(GlModal).find(GlEmoji); describe('rendering the modal', () => { beforeEach(() => { @@ -138,15 +176,15 @@ describe('InviteMembersModal', () => { }); it('renders the modal with the correct title', () => { - expect(wrapper.findComponent(GlModal).props('title')).toBe('Invite members'); + expect(wrapper.findComponent(GlModal).props('title')).toBe(MEMBERS_MODAL_DEFAULT_TITLE); }); it('renders the Cancel button text correctly', () => { - expect(findCancelButton().text()).toBe('Cancel'); + expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT); }); it('renders the Invite button text correctly', () => { - expect(findInviteButton().text()).toBe('Invite'); + expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT); }); it('renders the Invite button modal without isLoading', () => { @@ -171,7 +209,7 @@ describe('InviteMembersModal', () => { describe('rendering the access expiration date field', () => { it('renders the datepicker', () => { - expect(findDatepicker()).toExist(); + expect(findDatepicker().exists()).toBe(true); }); }); }); @@ -191,14 +229,164 @@ describe('InviteMembersModal', () => { }); }); + describe('rendering the tasks to be done', () => { + const setupComponent = ( + extraData = {}, + props = {}, + urlParameter = ['invite_members_for_task'], + ) => { + const data = { + selectedAccessLevel: 30, + selectedTasksToBeDone: ['ci', 'code'], + ...extraData, + }; + getParameterValues.mockImplementation(() => urlParameter); + createComponent(data, props); + }; + + afterAll(() => { + getParameterValues.mockImplementation(() => []); + }); + + it('renders the tasks to be done', () => { + setupComponent(); + + expect(findTasksToBeDone().exists()).toBe(true); + }); + + describe('when the selected access level is lower than 30', () => { + it('does not render the tasks to be done', () => { + setupComponent({ selectedAccessLevel: 20 }); + + expect(findTasksToBeDone().exists()).toBe(false); + }); + }); + + describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => { + it('does not render the tasks to be done', () => { + setupComponent({}, {}, []); + + expect(findTasksToBeDone().exists()).toBe(false); + }); + }); + + describe('rendering the tasks', () => { + it('renders the tasks', () => { + setupComponent(); + + expect(findTasks().exists()).toBe(true); + }); + + it('does not render an alert', () => { + setupComponent(); + + expect(findNoProjectsAlert().exists()).toBe(false); + }); + + describe('when there are no projects passed in the data', () => { + it('does not render the tasks', () => { + setupComponent({}, { projects: [] }); + + expect(findTasks().exists()).toBe(false); + }); + + it('renders an alert with a link to the new projects path', () => { + setupComponent({}, { projects: [] }); + + expect(findNoProjectsAlert().exists()).toBe(true); + expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe( + newProjectPath, + ); + }); + }); + }); + + describe('rendering the project dropdown', () => { + it('renders the project select', () => { + setupComponent(); + + expect(findProjectSelect().exists()).toBe(true); + }); + + describe('when the modal is shown for a project', () => { + it('does not render the project select', () => { + setupComponent({}, { isProject: true }); + + expect(findProjectSelect().exists()).toBe(false); + }); + }); + + describe('when no tasks are selected', () => { + it('does not render the project select', () => { + setupComponent({ selectedTasksToBeDone: [] }); + + expect(findProjectSelect().exists()).toBe(false); + }); + }); + }); + + describe('tracking events', () => { + it('tracks the view for invite_members_for_task', () => { + setupComponent(); + + expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + INVITE_MEMBERS_FOR_TASK.view, + ); + }); + + it('tracks the submit for invite_members_for_task', () => { + setupComponent(); + clickInviteButton(); + + expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name, { + label: 'selected_tasks_to_be_done', + property: 'ci,code', + }); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + INVITE_MEMBERS_FOR_TASK.submit, + ); + }); + }); + }); + describe('displaying the correct introText and form group description', () => { describe('when inviting to a project', () => { describe('when inviting members', () => { - it('includes the correct invitee, type, and formatted name', () => { + beforeEach(() => { createInviteMembersToProjectWrapper(); + }); + it('renders the modal without confetti', () => { + expect(wrapper.findComponent(ModalConfetti).exists()).toBe(false); + }); + + it('includes the correct invitee, type, and formatted name', () => { expect(findIntroText()).toBe("You're inviting members to the test name project."); - expect(membersFormGroupDescription()).toBe('Select members or type email addresses'); + expect(findCelebrationEmoji().exists()).toBe(false); + expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); + }); + }); + + describe('when inviting members with celebration', () => { + beforeEach(() => { + createComponent({ mode: 'celebrate', inviteeType: 'members' }, { isProject: true }); + }); + + it('renders the modal with confetti', () => { + expect(wrapper.findComponent(ModalConfetti).exists()).toBe(true); + }); + + it('renders the modal with the correct title', () => { + expect(wrapper.findComponent(GlModal).props('title')).toBe(MEMBERS_MODAL_CELEBRATE_TITLE); + }); + + it('includes the correct celebration text and emoji', () => { + expect(findIntroText()).toBe( + `${MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT} ${MEMBERS_MODAL_CELEBRATE_INTRO}`, + ); + expect(findCelebrationEmoji().exists()).toBe(true); + expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); }); }); @@ -218,7 +406,7 @@ describe('InviteMembersModal', () => { createInviteMembersToGroupWrapper(); expect(findIntroText()).toBe("You're inviting members to the test name group."); - expect(membersFormGroupDescription()).toBe('Select members or type email addresses'); + expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); }); }); @@ -267,6 +455,8 @@ describe('InviteMembersModal', () => { invite_source: inviteSource, format: 'json', areas_of_focus: noSelectionAreasOfFocus, + tasks_to_be_done: [], + tasks_project_id: '', }; describe('when member is added successfully', () => { @@ -448,6 +638,8 @@ describe('InviteMembersModal', () => { email: 'email@example.com', invite_source: inviteSource, areas_of_focus: noSelectionAreasOfFocus, + tasks_to_be_done: [], + tasks_project_id: '', format: 'json', }; @@ -576,6 +768,8 @@ describe('InviteMembersModal', () => { invite_source: inviteSource, areas_of_focus: noSelectionAreasOfFocus, format: 'json', + tasks_to_be_done: [], + tasks_project_id: '', }; const emailPostData = { ...postData, email: 'email@example.com' }; diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index b2ebb9e4a47..3fce23f854c 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -1,8 +1,9 @@ -import { GlButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import eventHub from '~/invite_members/event_hub'; +import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '~/invite_members/constants'; jest.mock('~/experimentation/experiment_tracking'); @@ -15,6 +16,7 @@ let findButton; const triggerComponent = { button: GlButton, anchor: GlLink, + 'side-nav': GlLink, }; const createComponent = (props = {}) => { @@ -27,9 +29,23 @@ const createComponent = (props = {}) => { }); }; -describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => { - triggerProps = { triggerElement, triggerSource }; - findButton = () => wrapper.findComponent(triggerComponent[triggerElement]); +const triggerItems = [ + { + triggerElement: TRIGGER_ELEMENT_BUTTON, + }, + { + triggerElement: 'anchor', + }, + { + triggerElement: TRIGGER_ELEMENT_SIDE_NAV, + icon: 'plus', + }, +]; + +describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => { + triggerProps = { ...triggerItem, triggerSource }; + + findButton = () => wrapper.findComponent(triggerComponent[triggerItem.triggerElement]); afterEach(() => { wrapper.destroy(); @@ -91,3 +107,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement }); }); }); + +describe('side-nav with icon', () => { + it('includes the specified icon with correct size when triggerElement is link', () => { + const findIcon = () => wrapper.findComponent(GlIcon); + + createComponent({ triggerElement: TRIGGER_ELEMENT_SIDE_NAV, icon: 'plus' }); + + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe('plus'); + }); +}); diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js index 307323ef07a..f4636fd7e6a 100644 --- a/spec/frontend/issuable/components/csv_import_modal_spec.js +++ b/spec/frontend/issuable/components/csv_import_modal_spec.js @@ -1,7 +1,8 @@ -import { GlButton, GlModal } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { stubComponent } from 'helpers/stub_component'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; +import { __ } from '~/locale'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -36,7 +37,6 @@ describe('CsvImportModal', () => { }); const findModal = () => wrapper.findComponent(GlModal); - const findPrimaryButton = () => wrapper.findComponent(GlButton); const findForm = () => wrapper.find('form'); const findFileInput = () => wrapper.findByLabelText('Upload CSV file'); const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); @@ -64,11 +64,11 @@ describe('CsvImportModal', () => { expect(findForm().exists()).toBe(true); expect(findForm().attributes('action')).toBe(importCsvIssuesPath); expect(findAuthenticityToken()).toBe('mock-csrf-token'); - expect(findFileInput()).toExist(); + expect(findFileInput().exists()).toBe(true); }); it('displays the correct primary button action text', () => { - expect(findPrimaryButton()).toExist(); + expect(findModal().props('actionPrimary')).toEqual({ text: __('Import issues') }); }); it('submits the form when the primary action is clicked', () => { diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index bd05cb1ac5a..e32215b4aa6 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -8,7 +8,7 @@ import IssuableApp from '~/issue_show/components/app.vue'; import DescriptionComponent from '~/issue_show/components/description.vue'; import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; import PinnedLinks from '~/issue_show/components/pinned_links.vue'; -import { IssuableStatus, IssuableStatusText } from '~/issue_show/constants'; +import { IssuableStatus, IssuableStatusText, POLLING_DELAY } from '~/issue_show/constants'; import eventHub from '~/issue_show/event_hub'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -643,4 +643,40 @@ describe('Issuable output', () => { }); }); }); + + describe('taskListUpdateStarted', () => { + it('stops polling', () => { + jest.spyOn(wrapper.vm.poll, 'stop'); + + wrapper.vm.taskListUpdateStarted(); + + expect(wrapper.vm.poll.stop).toHaveBeenCalled(); + }); + }); + + describe('taskListUpdateSucceeded', () => { + it('enables polling', () => { + jest.spyOn(wrapper.vm.poll, 'enable'); + jest.spyOn(wrapper.vm.poll, 'makeDelayedRequest'); + + wrapper.vm.taskListUpdateSucceeded(); + + expect(wrapper.vm.poll.enable).toHaveBeenCalled(); + expect(wrapper.vm.poll.makeDelayedRequest).toHaveBeenCalledWith(POLLING_DELAY); + }); + }); + + describe('taskListUpdateFailed', () => { + it('enables polling and calls updateStoreState', () => { + jest.spyOn(wrapper.vm.poll, 'enable'); + jest.spyOn(wrapper.vm.poll, 'makeDelayedRequest'); + jest.spyOn(wrapper.vm, 'updateStoreState'); + + wrapper.vm.taskListUpdateFailed(); + + expect(wrapper.vm.poll.enable).toHaveBeenCalled(); + expect(wrapper.vm.poll.makeDelayedRequest).toHaveBeenCalledWith(POLLING_DELAY); + expect(wrapper.vm.updateStoreState).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js index cdf06ecc31f..bdcc82cab81 100644 --- a/spec/frontend/issue_show/components/description_spec.js +++ b/spec/frontend/issue_show/components/description_spec.js @@ -114,6 +114,8 @@ describe('Description component', () => { dataType: 'issuableType', fieldName: 'description', selector: '.detail-page-description', + onUpdate: expect.any(Function), + onSuccess: expect.any(Function), onError: expect.any(Function), lockVersion: 0, }); @@ -150,6 +152,26 @@ describe('Description component', () => { }); }); + describe('taskListUpdateStarted', () => { + it('emits event to parent', () => { + const spy = jest.spyOn(vm, '$emit'); + + vm.taskListUpdateStarted(); + + expect(spy).toHaveBeenCalledWith('taskListUpdateStarted'); + }); + }); + + describe('taskListUpdateSuccess', () => { + it('emits event to parent', () => { + const spy = jest.spyOn(vm, '$emit'); + + vm.taskListUpdateSuccess(); + + expect(spy).toHaveBeenCalledWith('taskListUpdateSucceeded'); + }); + }); + describe('taskListUpdateError', () => { it('should create flash notification and emit an event to parent', () => { const msg = diff --git a/spec/frontend/issue_show/components/fields/type_spec.js b/spec/frontend/issue_show/components/fields/type_spec.js index fac745716d7..95ae6f37877 100644 --- a/spec/frontend/issue_show/components/fields/type_spec.js +++ b/spec/frontend/issue_show/components/fields/type_spec.js @@ -39,7 +39,7 @@ describe('Issue type field component', () => { const findTypeFromDropDownItemIconAt = (at) => findTypeFromDropDownItems().at(at).findComponent(GlIcon); - const createComponent = ({ data } = {}) => { + const createComponent = ({ data } = {}, provide) => { fakeApollo = createMockApollo([], mockResolvers); wrapper = shallowMount(IssueTypeField, { @@ -51,6 +51,10 @@ describe('Issue type field component', () => { ...data, }; }, + provide: { + canCreateIncident: true, + ...provide, + }, }); }; @@ -92,5 +96,25 @@ describe('Issue type field component', () => { await wrapper.vm.$nextTick(); expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); }); + + describe('when user is a guest', () => { + it('hides the incident type from the dropdown', async () => { + createComponent({}, { canCreateIncident: false, issueType: 'issue' }); + await waitForPromises(); + + expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); + expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(false); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + }); + + it('and incident is selected, includes incident in the dropdown', async () => { + createComponent({}, { canCreateIncident: false, issueType: 'incident' }); + await waitForPromises(); + + expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); + expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(true); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + }); + }); }); }); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 6b443062f12..3f52c7b4afe 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -37,6 +37,7 @@ import { TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, urlSortParams, @@ -581,6 +582,7 @@ describe('IssuesListApp component', () => { { type: TOKEN_TYPE_MILESTONE }, { type: TOKEN_TYPE_LABEL }, { type: TOKEN_TYPE_TYPE }, + { type: TOKEN_TYPE_RELEASE }, { type: TOKEN_TYPE_MY_REACTION }, { type: TOKEN_TYPE_CONFIDENTIAL }, { type: TOKEN_TYPE_ITERATION }, diff --git a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js b/spec/frontend/issues_list/components/new_issue_dropdown_spec.js index 1fcaa99cf5a..1c9a87e8af2 100644 --- a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js +++ b/spec/frontend/issues_list/components/new_issue_dropdown_spec.js @@ -8,7 +8,7 @@ import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { emptySearchProjectsQueryResponse, project1, - project2, + project3, searchProjectsQueryResponse, } from '../mock_data'; @@ -72,7 +72,7 @@ describe('NewIssueDropdown component', () => { expect(inputSpy).toHaveBeenCalledTimes(1); }); - it('renders expected dropdown items', async () => { + it('renders projects with issues enabled', async () => { wrapper = mountComponent({ mountFn: mount }); await showDropdown(); @@ -80,7 +80,7 @@ describe('NewIssueDropdown component', () => { const listItems = wrapper.findAll('li'); expect(listItems.at(0).text()).toBe(project1.nameWithNamespace); - expect(listItems.at(1).text()).toBe(project2.nameWithNamespace); + expect(listItems.at(1).text()).toBe(project3.nameWithNamespace); }); it('renders `No matches found` when there are no matches', async () => { diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index 3be256d8094..19a8af4d9c2 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -95,16 +95,29 @@ export const locationSearch = [ 'assignee_username[]=lisa', 'not[assignee_username][]=patty', 'not[assignee_username][]=selma', + 'milestone_title=season+3', 'milestone_title=season+4', 'not[milestone_title]=season+20', + 'not[milestone_title]=season+30', 'label_name[]=cartoon', 'label_name[]=tv', 'not[label_name][]=live action', 'not[label_name][]=drama', + 'release_tag=v3', + 'release_tag=v4', + 'not[release_tag]=v20', + 'not[release_tag]=v30', + 'type[]=issue', + 'type[]=feature', + 'not[type][]=bug', + 'not[type][]=incident', 'my_reaction_emoji=thumbsup', - 'confidential=no', + 'not[my_reaction_emoji]=thumbsdown', + 'confidential=yes', 'iteration_id=4', + 'iteration_id=12', 'not[iteration_id]=20', + 'not[iteration_id]=42', 'epic_id=12', 'not[epic_id]=34', 'weight=1', @@ -114,10 +127,10 @@ export const locationSearch = [ export const locationSearchWithSpecialValues = [ 'assignee_id=123', 'assignee_username=bart', - 'type[]=issue', - 'type[]=incident', 'my_reaction_emoji=None', 'iteration_id=Current', + 'label_name[]=None', + 'release_tag=None', 'milestone_title=Upcoming', 'epic_id=None', 'weight=None', @@ -130,16 +143,29 @@ export const filteredTokens = [ { type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS } }, { type: 'assignee_username', value: { data: 'patty', operator: OPERATOR_IS_NOT } }, { type: 'assignee_username', value: { data: 'selma', operator: OPERATOR_IS_NOT } }, + { type: 'milestone', value: { data: 'season 3', operator: OPERATOR_IS } }, { type: 'milestone', value: { data: 'season 4', operator: OPERATOR_IS } }, { type: 'milestone', value: { data: 'season 20', operator: OPERATOR_IS_NOT } }, + { type: 'milestone', value: { data: 'season 30', operator: OPERATOR_IS_NOT } }, { type: 'labels', value: { data: 'cartoon', operator: OPERATOR_IS } }, { type: 'labels', value: { data: 'tv', operator: OPERATOR_IS } }, { type: 'labels', value: { data: 'live action', operator: OPERATOR_IS_NOT } }, { type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } }, + { type: 'release', value: { data: 'v3', operator: OPERATOR_IS } }, + { type: 'release', value: { data: 'v4', operator: OPERATOR_IS } }, + { type: 'release', value: { data: 'v20', operator: OPERATOR_IS_NOT } }, + { type: 'release', value: { data: 'v30', operator: OPERATOR_IS_NOT } }, + { type: 'type', value: { data: 'issue', operator: OPERATOR_IS } }, + { type: 'type', value: { data: 'feature', operator: OPERATOR_IS } }, + { type: 'type', value: { data: 'bug', operator: OPERATOR_IS_NOT } }, + { type: 'type', value: { data: 'incident', operator: OPERATOR_IS_NOT } }, { type: 'my_reaction_emoji', value: { data: 'thumbsup', operator: OPERATOR_IS } }, - { type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } }, + { type: 'my_reaction_emoji', value: { data: 'thumbsdown', operator: OPERATOR_IS_NOT } }, + { type: 'confidential', value: { data: 'yes', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: '4', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: '12', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: '20', operator: OPERATOR_IS_NOT } }, + { type: 'iteration', value: { data: '42', operator: OPERATOR_IS_NOT } }, { type: 'epic_id', value: { data: '12', operator: OPERATOR_IS } }, { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, @@ -151,10 +177,10 @@ export const filteredTokens = [ export const filteredTokensWithSpecialValues = [ { type: 'assignee_username', value: { data: '123', operator: OPERATOR_IS } }, { type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } }, - { type: 'type', value: { data: 'issue', operator: OPERATOR_IS } }, - { type: 'type', value: { data: 'incident', operator: OPERATOR_IS } }, { type: 'my_reaction_emoji', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } }, + { type: 'labels', value: { data: 'None', operator: OPERATOR_IS } }, + { type: 'release', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'milestone', value: { data: 'Upcoming', operator: OPERATOR_IS } }, { type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'weight', value: { data: 'None', operator: OPERATOR_IS } }, @@ -163,19 +189,24 @@ export const filteredTokensWithSpecialValues = [ export const apiParams = { authorUsername: 'homer', assigneeUsernames: ['bart', 'lisa'], - milestoneTitle: 'season 4', + milestoneTitle: ['season 3', 'season 4'], labelName: ['cartoon', 'tv'], + releaseTag: ['v3', 'v4'], + types: ['ISSUE', 'FEATURE'], myReactionEmoji: 'thumbsup', - confidential: 'no', - iterationId: '4', + confidential: true, + iterationId: ['4', '12'], epicId: '12', weight: '1', not: { authorUsername: 'marge', assigneeUsernames: ['patty', 'selma'], - milestoneTitle: 'season 20', + milestoneTitle: ['season 20', 'season 30'], labelName: ['live action', 'drama'], - iterationId: '20', + releaseTag: ['v20', 'v30'], + types: ['BUG', 'INCIDENT'], + myReactionEmoji: 'thumbsdown', + iterationId: ['20', '42'], epicId: '34', weight: '3', }, @@ -184,8 +215,9 @@ export const apiParams = { export const apiParamsWithSpecialValues = { assigneeId: '123', assigneeUsernames: 'bart', - types: ['ISSUE', 'INCIDENT'], + labelName: 'None', myReactionEmoji: 'None', + releaseTagWildcardId: 'NONE', iterationWildcardId: 'CURRENT', milestoneWildcardId: 'UPCOMING', epicId: 'None', @@ -197,14 +229,19 @@ export const urlParams = { 'not[author_username]': 'marge', 'assignee_username[]': ['bart', 'lisa'], 'not[assignee_username][]': ['patty', 'selma'], - milestone_title: 'season 4', - 'not[milestone_title]': 'season 20', + milestone_title: ['season 3', 'season 4'], + 'not[milestone_title]': ['season 20', 'season 30'], 'label_name[]': ['cartoon', 'tv'], 'not[label_name][]': ['live action', 'drama'], + release_tag: ['v3', 'v4'], + 'not[release_tag]': ['v20', 'v30'], + 'type[]': ['issue', 'feature'], + 'not[type][]': ['bug', 'incident'], my_reaction_emoji: 'thumbsup', - confidential: 'no', - iteration_id: '4', - 'not[iteration_id]': '20', + 'not[my_reaction_emoji]': 'thumbsdown', + confidential: 'yes', + iteration_id: ['4', '12'], + 'not[iteration_id]': ['20', '42'], epic_id: '12', 'not[epic_id]': '34', weight: '1', @@ -214,7 +251,8 @@ export const urlParams = { export const urlParamsWithSpecialValues = { assignee_id: '123', 'assignee_username[]': 'bart', - 'type[]': ['issue', 'incident'], + 'label_name[]': 'None', + release_tag: 'None', my_reaction_emoji: 'None', iteration_id: 'Current', milestone_title: 'Upcoming', @@ -224,6 +262,7 @@ export const urlParamsWithSpecialValues = { export const project1 = { id: 'gid://gitlab/Group/26', + issuesEnabled: true, name: 'Super Mario Project', nameWithNamespace: 'Mushroom Kingdom / Super Mario Project', webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/super-mario-project', @@ -231,16 +270,25 @@ export const project1 = { export const project2 = { id: 'gid://gitlab/Group/59', + issuesEnabled: false, name: 'Mario Kart Project', nameWithNamespace: 'Mushroom Kingdom / Mario Kart Project', webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-kart-project', }; +export const project3 = { + id: 'gid://gitlab/Group/103', + issuesEnabled: true, + name: 'Mario Party Project', + nameWithNamespace: 'Mushroom Kingdom / Mario Party Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-party-project', +}; + export const searchProjectsQueryResponse = { data: { group: { projects: { - nodes: [project1, project2], + nodes: [project1, project2, project3], }, }, }, diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues_list/utils_spec.js index 458776d9ec5..8e1d70db92d 100644 --- a/spec/frontend/issues_list/utils_spec.js +++ b/spec/frontend/issues_list/utils_spec.js @@ -58,10 +58,10 @@ describe('getDueDateValue', () => { describe('getSortOptions', () => { describe.each` hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking - ${false} | ${false} | ${8} | ${false} | ${false} - ${true} | ${false} | ${9} | ${true} | ${false} - ${false} | ${true} | ${9} | ${false} | ${true} - ${true} | ${true} | ${10} | ${true} | ${true} + ${false} | ${false} | ${9} | ${false} | ${false} + ${true} | ${false} | ${10} | ${true} | ${false} + ${false} | ${true} | ${10} | ${false} | ${true} + ${true} | ${true} | ${11} | ${true} | ${true} `( 'when hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature', ({ diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js new file mode 100644 index 00000000000..5ec1b7b7932 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_button_spec.js @@ -0,0 +1,44 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import AddNamespaceButton from '~/jira_connect/subscriptions/components/add_namespace_button.vue'; +import AddNamespaceModal from '~/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue'; +import { ADD_NAMESPACE_MODAL_ID } from '~/jira_connect/subscriptions/constants'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('AddNamespaceButton', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(AddNamespaceButton, { + directives: { + glModal: createMockDirective(), + }, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + const findModal = () => wrapper.findComponent(AddNamespaceModal); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a button', () => { + expect(findButton().exists()).toBe(true); + }); + + it('contains a modal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('button is bound to the modal', () => { + const { value } = getBinding(findButton().element, 'gl-modal'); + + expect(value).toBeTruthy(); + expect(value).toBe(ADD_NAMESPACE_MODAL_ID); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js new file mode 100644 index 00000000000..d80381107f2 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal_spec.js @@ -0,0 +1,36 @@ +import { shallowMount } from '@vue/test-utils'; +import AddNamespaceModal from '~/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue'; +import GroupsList from '~/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue'; +import { ADD_NAMESPACE_MODAL_ID } from '~/jira_connect/subscriptions/constants'; + +describe('AddNamespaceModal', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(AddNamespaceModal); + }; + + const findModal = () => wrapper.findComponent(AddNamespaceModal); + const findGroupsList = () => wrapper.findComponent(GroupsList); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays modal with correct props', () => { + const modal = findModal(); + expect(modal.exists()).toBe(true); + expect(modal.attributes()).toMatchObject({ + modalid: ADD_NAMESPACE_MODAL_ID, + title: AddNamespaceModal.modal.title, + }); + }); + + it('displays GroupList', () => { + expect(findGroupsList().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js new file mode 100644 index 00000000000..15e9a740c83 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js @@ -0,0 +1,112 @@ +import { GlButton } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; + +import * as JiraConnectApi from '~/jira_connect/subscriptions/api'; +import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue'; +import GroupsListItem from '~/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue'; +import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; +import { mockGroup1 } from '../../mock_data'; + +jest.mock('~/jira_connect/subscriptions/utils'); + +describe('GroupsListItem', () => { + let wrapper; + const mockSubscriptionPath = 'subscriptionPath'; + + const createComponent = ({ mountFn = shallowMount } = {}) => { + wrapper = mountFn(GroupsListItem, { + propsData: { + group: mockGroup1, + }, + provide: { + subscriptionsPath: mockSubscriptionPath, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGroupItemName = () => wrapper.findComponent(GroupItemName); + const findLinkButton = () => wrapper.findComponent(GlButton); + const clickLinkButton = () => findLinkButton().trigger('click'); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GroupItemName', () => { + expect(findGroupItemName().exists()).toBe(true); + expect(findGroupItemName().props('group')).toBe(mockGroup1); + }); + + it('renders Link button', () => { + expect(findLinkButton().exists()).toBe(true); + expect(findLinkButton().text()).toBe('Link'); + }); + }); + + describe('on Link button click', () => { + let addSubscriptionSpy; + + beforeEach(() => { + createComponent({ mountFn: mount }); + + addSubscriptionSpy = jest.spyOn(JiraConnectApi, 'addSubscription').mockResolvedValue(); + }); + + it('sets button to loading and sends request', async () => { + expect(findLinkButton().props('loading')).toBe(false); + + clickLinkButton(); + + await wrapper.vm.$nextTick(); + + expect(findLinkButton().props('loading')).toBe(true); + + await waitForPromises(); + + expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path); + expect(persistAlert).toHaveBeenCalledWith({ + linkUrl: '/help/integration/jira_development_panel.html#usage', + message: + 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', + title: 'Namespace successfully linked', + variant: 'success', + }); + }); + + describe('when request is successful', () => { + it('reloads the page', async () => { + clickLinkButton(); + + await waitForPromises(); + + expect(reloadPage).toHaveBeenCalled(); + }); + }); + + describe('when request has errors', () => { + const mockErrorMessage = 'error message'; + const mockError = { response: { data: { error: mockErrorMessage } } }; + + beforeEach(() => { + addSubscriptionSpy = jest + .spyOn(JiraConnectApi, 'addSubscription') + .mockRejectedValue(mockError); + }); + + it('emits `error` event', async () => { + clickLinkButton(); + + await waitForPromises(); + + expect(reloadPage).not.toHaveBeenCalled(); + expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage); + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js new file mode 100644 index 00000000000..04aba8bda23 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js @@ -0,0 +1,303 @@ +import { GlAlert, GlLoadingIcon, GlSearchBoxByType, GlPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { fetchGroups } from '~/jira_connect/subscriptions/api'; +import GroupsList from '~/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue'; +import GroupsListItem from '~/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue'; +import { DEFAULT_GROUPS_PER_PAGE } from '~/jira_connect/subscriptions/constants'; +import { mockGroup1, mockGroup2 } from '../../mock_data'; + +const createMockGroup = (groupId) => { + return { + ...mockGroup1, + id: groupId, + }; +}; + +const createMockGroups = (count) => { + return [...new Array(count)].map((_, idx) => createMockGroup(idx)); +}; + +jest.mock('~/jira_connect/subscriptions/api', () => { + return { + fetchGroups: jest.fn(), + }; +}); + +const mockGroupsPath = '/groups'; + +describe('GroupsList', () => { + let wrapper; + + const mockEmptyResponse = { data: [] }; + + const createComponent = (options = {}) => { + wrapper = extendedWrapper( + shallowMount(GroupsList, { + provide: { + groupsPath: mockGroupsPath, + }, + ...options, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlAlert = () => wrapper.findComponent(GlAlert); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAllItems = () => wrapper.findAll(GroupsListItem); + const findFirstItem = () => findAllItems().at(0); + const findSecondItem = () => findAllItems().at(1); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findGroupsList = () => wrapper.findByTestId('groups-list'); + const findPagination = () => wrapper.findComponent(GlPagination); + + describe('when groups are loading', () => { + it('renders loading icon', async () => { + fetchGroups.mockReturnValue(new Promise(() => {})); + createComponent(); + + await wrapper.vm.$nextTick(); + + expect(findGlLoadingIcon().exists()).toBe(true); + }); + }); + + describe('when groups fetch fails', () => { + it('renders error message', async () => { + fetchGroups.mockRejectedValue(); + createComponent(); + + await waitForPromises(); + + expect(findGlLoadingIcon().exists()).toBe(false); + expect(findGlAlert().exists()).toBe(true); + expect(findGlAlert().text()).toBe('Failed to load namespaces. Please try again.'); + }); + }); + + describe('with no groups returned', () => { + it('renders empty state', async () => { + fetchGroups.mockResolvedValue(mockEmptyResponse); + createComponent(); + + await waitForPromises(); + + expect(findGlLoadingIcon().exists()).toBe(false); + expect(wrapper.text()).toContain('No available namespaces'); + }); + }); + + describe('with groups returned', () => { + beforeEach(async () => { + fetchGroups.mockResolvedValue({ + headers: { 'X-PAGE': 1, 'X-TOTAL': 2 }, + data: [mockGroup1, mockGroup2], + }); + createComponent(); + + await waitForPromises(); + }); + + it('renders groups list', () => { + expect(findAllItems()).toHaveLength(2); + expect(findFirstItem().props('group')).toBe(mockGroup1); + expect(findSecondItem().props('group')).toBe(mockGroup2); + }); + + it('sets GroupListItem `disabled` prop to `false`', () => { + findAllItems().wrappers.forEach((groupListItem) => { + expect(groupListItem.props('disabled')).toBe(false); + }); + }); + + it('does not set opacity of the groups list', () => { + expect(findGroupsList().classes()).not.toContain('gl-opacity-5'); + }); + + it('shows error message on $emit from item', async () => { + const errorMessage = 'error message'; + + findFirstItem().vm.$emit('error', errorMessage); + + await wrapper.vm.$nextTick(); + + expect(findGlAlert().exists()).toBe(true); + expect(findGlAlert().text()).toContain(errorMessage); + }); + + describe('when searching groups', () => { + const mockSearchTeam = 'mock search term'; + + describe('while groups are loading', () => { + beforeEach(async () => { + fetchGroups.mockClear(); + fetchGroups.mockReturnValue(new Promise(() => {})); + + findSearchBox().vm.$emit('input', mockSearchTeam); + await wrapper.vm.$nextTick(); + }); + + it('calls `fetchGroups` with search term', () => { + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 1, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: mockSearchTeam, + }); + }); + + it('disables GroupListItems', () => { + findAllItems().wrappers.forEach((groupListItem) => { + expect(groupListItem.props('disabled')).toBe(true); + }); + }); + + it('sets opacity of the groups list', () => { + expect(findGroupsList().classes()).toContain('gl-opacity-5'); + }); + + it('sets loading prop of the search box', () => { + expect(findSearchBox().props('isLoading')).toBe(true); + }); + + it('sets value prop of the search box to the search term', () => { + expect(findSearchBox().props('value')).toBe(mockSearchTeam); + }); + }); + + describe('when group search finishes loading', () => { + beforeEach(async () => { + fetchGroups.mockResolvedValue({ data: [mockGroup1] }); + findSearchBox().vm.$emit('input'); + + await waitForPromises(); + }); + + it('renders new groups list', () => { + expect(findAllItems()).toHaveLength(1); + expect(findFirstItem().props('group')).toBe(mockGroup1); + }); + }); + + it.each` + userSearchTerm | finalSearchTerm + ${'gitl'} | ${'gitl'} + ${'git'} | ${'git'} + ${'gi'} | ${''} + ${'g'} | ${''} + ${''} | ${''} + ${undefined} | ${undefined} + `( + 'searches for "$finalSearchTerm" when user enters "$userSearchTerm"', + async ({ userSearchTerm, finalSearchTerm }) => { + fetchGroups.mockResolvedValue({ + data: [mockGroup1], + headers: { 'X-PAGE': 1, 'X-TOTAL': 1 }, + }); + + createComponent(); + await waitForPromises(); + + const searchBox = findSearchBox(); + searchBox.vm.$emit('input', userSearchTerm); + + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 1, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: finalSearchTerm, + }); + }, + ); + }); + + describe('when page=2', () => { + beforeEach(async () => { + const totalItems = DEFAULT_GROUPS_PER_PAGE + 1; + const mockGroups = createMockGroups(totalItems); + fetchGroups.mockResolvedValue({ + headers: { 'X-TOTAL': totalItems, 'X-PAGE': 1 }, + data: mockGroups, + }); + createComponent(); + await waitForPromises(); + + const paginationEl = findPagination(); + paginationEl.vm.$emit('input', 2); + }); + + it('should load results for page 2', () => { + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 2, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: '', + }); + }); + + it('resets page to 1 on search `input` event', () => { + const mockSearchTerm = 'gitlab'; + const searchBox = findSearchBox(); + + searchBox.vm.$emit('input', mockSearchTerm); + + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 1, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: mockSearchTerm, + }); + }); + }); + }); + + describe('pagination', () => { + it.each` + scenario | totalItems | shouldShowPagination + ${'renders pagination'} | ${DEFAULT_GROUPS_PER_PAGE + 1} | ${true} + ${'does not render pagination'} | ${DEFAULT_GROUPS_PER_PAGE} | ${false} + ${'does not render pagination'} | ${2} | ${false} + ${'does not render pagination'} | ${0} | ${false} + `('$scenario with $totalItems groups', async ({ totalItems, shouldShowPagination }) => { + const mockGroups = createMockGroups(totalItems); + fetchGroups.mockResolvedValue({ + headers: { 'X-TOTAL': totalItems, 'X-PAGE': 1 }, + data: mockGroups, + }); + createComponent(); + await waitForPromises(); + + const paginationEl = findPagination(); + + expect(paginationEl.exists()).toBe(shouldShowPagination); + if (shouldShowPagination) { + expect(paginationEl.props('totalItems')).toBe(totalItems); + } + }); + + describe('when `input` event triggered', () => { + beforeEach(async () => { + const MOCK_TOTAL_ITEMS = DEFAULT_GROUPS_PER_PAGE + 1; + fetchGroups.mockResolvedValue({ + headers: { 'X-TOTAL': MOCK_TOTAL_ITEMS, 'X-PAGE': 1 }, + data: createMockGroups(MOCK_TOTAL_ITEMS), + }); + + createComponent(); + await waitForPromises(); + }); + + it('executes `fetchGroups` with correct arguments', () => { + const paginationEl = findPagination(); + paginationEl.vm.$emit('input', 2); + + expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { + page: 2, + perPage: DEFAULT_GROUPS_PER_PAGE, + search: '', + }); + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js index 8915a7697a5..8e464968453 100644 --- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js @@ -1,14 +1,17 @@ -import { GlAlert, GlButton, GlModal, GlLink } from '@gitlab/ui'; +import { GlAlert, GlLink, GlEmptyState } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue'; +import AddNamespaceButton from '~/jira_connect/subscriptions/components/add_namespace_button.vue'; +import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue'; +import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; import createStore from '~/jira_connect/subscriptions/store'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { __ } from '~/locale'; +import { mockSubscription } from '../mock_data'; jest.mock('~/jira_connect/subscriptions/utils', () => ({ retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }), - getLocation: jest.fn(), })); describe('JiraConnectApp', () => { @@ -17,8 +20,10 @@ describe('JiraConnectApp', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findAlertLink = () => findAlert().findComponent(GlLink); - const findGlButton = () => wrapper.findComponent(GlButton); - const findGlModal = () => wrapper.findComponent(GlModal); + const findSignInButton = () => wrapper.findComponent(SignInButton); + const findAddNamespaceButton = () => wrapper.findComponent(AddNamespaceButton); + const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); const createComponent = ({ provide, mountFn = shallowMount } = {}) => { store = createStore(); @@ -34,96 +39,115 @@ describe('JiraConnectApp', () => { }); describe('template', () => { - describe('when user is not logged in', () => { - beforeEach(() => { - createComponent({ - provide: { - usersPath: '/users', - }, + describe.each` + scenario | usersPath | subscriptions | expectSignInButton | expectEmptyState | expectNamespaceButton | expectSubscriptionsList + ${'user is not signed in with subscriptions'} | ${'/users'} | ${[mockSubscription]} | ${true} | ${false} | ${false} | ${true} + ${'user is not signed in without subscriptions'} | ${'/users'} | ${undefined} | ${true} | ${false} | ${false} | ${false} + ${'user is signed in with subscriptions'} | ${undefined} | ${[mockSubscription]} | ${false} | ${false} | ${true} | ${true} + ${'user is signed in without subscriptions'} | ${undefined} | ${undefined} | ${false} | ${true} | ${false} | ${false} + `( + 'when $scenario', + ({ + usersPath, + expectSignInButton, + subscriptions, + expectEmptyState, + expectNamespaceButton, + expectSubscriptionsList, + }) => { + beforeEach(() => { + createComponent({ + provide: { + usersPath, + subscriptions, + }, + }); }); - }); - it('renders "Sign in" button', () => { - expect(findGlButton().text()).toBe('Sign in to add namespaces'); - expect(findGlModal().exists()).toBe(false); - }); - }); + it(`${expectSignInButton ? 'renders' : 'does not render'} sign in button`, () => { + expect(findSignInButton().exists()).toBe(expectSignInButton); + }); - describe('when user is logged in', () => { - beforeEach(() => { - createComponent(); - }); + it(`${expectEmptyState ? 'renders' : 'does not render'} empty state`, () => { + expect(findEmptyState().exists()).toBe(expectEmptyState); + }); - it('renders "Add" button and modal', () => { - expect(findGlButton().text()).toBe('Add namespace'); - expect(findGlModal().exists()).toBe(true); - }); - }); + it(`${ + expectNamespaceButton ? 'renders' : 'does not render' + } button to add namespace`, () => { + expect(findAddNamespaceButton().exists()).toBe(expectNamespaceButton); + }); + + it(`${expectSubscriptionsList ? 'renders' : 'does not render'} subscriptions list`, () => { + expect(findSubscriptionsList().exists()).toBe(expectSubscriptionsList); + }); + }, + ); + }); - describe('alert', () => { - it.each` - message | variant | alertShouldRender - ${'Test error'} | ${'danger'} | ${true} - ${'Test notice'} | ${'info'} | ${true} - ${''} | ${undefined} | ${false} - ${undefined} | ${undefined} | ${false} - `( - 'renders correct alert when message is `$message` and variant is `$variant`', - async ({ message, alertShouldRender, variant }) => { - createComponent(); - - store.commit(SET_ALERT, { message, variant }); - await wrapper.vm.$nextTick(); - - const alert = findAlert(); - - expect(alert.exists()).toBe(alertShouldRender); - if (alertShouldRender) { - expect(alert.isVisible()).toBe(alertShouldRender); - expect(alert.html()).toContain(message); - expect(alert.props('variant')).toBe(variant); - expect(findAlertLink().exists()).toBe(false); - } - }, - ); - - it('hides alert on @dismiss event', async () => { + describe('alert', () => { + it.each` + message | variant | alertShouldRender + ${'Test error'} | ${'danger'} | ${true} + ${'Test notice'} | ${'info'} | ${true} + ${''} | ${undefined} | ${false} + ${undefined} | ${undefined} | ${false} + `( + 'renders correct alert when message is `$message` and variant is `$variant`', + async ({ message, alertShouldRender, variant }) => { createComponent(); - store.commit(SET_ALERT, { message: 'test message' }); + store.commit(SET_ALERT, { message, variant }); await wrapper.vm.$nextTick(); - findAlert().vm.$emit('dismiss'); - await wrapper.vm.$nextTick(); + const alert = findAlert(); - expect(findAlert().exists()).toBe(false); - }); + expect(alert.exists()).toBe(alertShouldRender); + if (alertShouldRender) { + expect(alert.isVisible()).toBe(alertShouldRender); + expect(alert.html()).toContain(message); + expect(alert.props('variant')).toBe(variant); + expect(findAlertLink().exists()).toBe(false); + } + }, + ); - it('renders link when `linkUrl` is set', async () => { - createComponent({ mountFn: mount }); + it('hides alert on @dismiss event', async () => { + createComponent(); - store.commit(SET_ALERT, { - message: __('test message %{linkStart}test link%{linkEnd}'), - linkUrl: 'https://gitlab.com', - }); - await wrapper.vm.$nextTick(); + store.commit(SET_ALERT, { message: 'test message' }); + await wrapper.vm.$nextTick(); + + findAlert().vm.$emit('dismiss'); + await wrapper.vm.$nextTick(); + + expect(findAlert().exists()).toBe(false); + }); - const alertLink = findAlertLink(); + it('renders link when `linkUrl` is set', async () => { + createComponent({ mountFn: mount }); - expect(alertLink.exists()).toBe(true); - expect(alertLink.text()).toContain('test link'); - expect(alertLink.attributes('href')).toBe('https://gitlab.com'); + store.commit(SET_ALERT, { + message: __('test message %{linkStart}test link%{linkEnd}'), + linkUrl: 'https://gitlab.com', }); + await wrapper.vm.$nextTick(); - describe('when alert is set in localStoage', () => { - it('renders alert on mount', () => { - createComponent(); + const alertLink = findAlertLink(); - const alert = findAlert(); + expect(alertLink.exists()).toBe(true); + expect(alertLink.text()).toContain('test link'); + expect(alertLink.attributes('href')).toBe('https://gitlab.com'); + }); - expect(alert.exists()).toBe(true); - expect(alert.html()).toContain('error message'); - }); + describe('when alert is set in localStoage', () => { + it('renders alert on mount', () => { + createComponent(); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.html()).toContain('error message'); }); }); }); diff --git a/spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js deleted file mode 100644 index b69435df83a..00000000000 --- a/spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js +++ /dev/null @@ -1,112 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; - -import * as JiraConnectApi from '~/jira_connect/subscriptions/api'; -import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue'; -import GroupsListItem from '~/jira_connect/subscriptions/components/groups_list_item.vue'; -import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; -import { mockGroup1 } from '../mock_data'; - -jest.mock('~/jira_connect/subscriptions/utils'); - -describe('GroupsListItem', () => { - let wrapper; - const mockSubscriptionPath = 'subscriptionPath'; - - const createComponent = ({ mountFn = shallowMount } = {}) => { - wrapper = mountFn(GroupsListItem, { - propsData: { - group: mockGroup1, - }, - provide: { - subscriptionsPath: mockSubscriptionPath, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findGroupItemName = () => wrapper.findComponent(GroupItemName); - const findLinkButton = () => wrapper.findComponent(GlButton); - const clickLinkButton = () => findLinkButton().trigger('click'); - - describe('template', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders GroupItemName', () => { - expect(findGroupItemName().exists()).toBe(true); - expect(findGroupItemName().props('group')).toBe(mockGroup1); - }); - - it('renders Link button', () => { - expect(findLinkButton().exists()).toBe(true); - expect(findLinkButton().text()).toBe('Link'); - }); - }); - - describe('on Link button click', () => { - let addSubscriptionSpy; - - beforeEach(() => { - createComponent({ mountFn: mount }); - - addSubscriptionSpy = jest.spyOn(JiraConnectApi, 'addSubscription').mockResolvedValue(); - }); - - it('sets button to loading and sends request', async () => { - expect(findLinkButton().props('loading')).toBe(false); - - clickLinkButton(); - - await wrapper.vm.$nextTick(); - - expect(findLinkButton().props('loading')).toBe(true); - - await waitForPromises(); - - expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path); - expect(persistAlert).toHaveBeenCalledWith({ - linkUrl: '/help/integration/jira_development_panel.html#usage', - message: - 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', - title: 'Namespace successfully linked', - variant: 'success', - }); - }); - - describe('when request is successful', () => { - it('reloads the page', async () => { - clickLinkButton(); - - await waitForPromises(); - - expect(reloadPage).toHaveBeenCalled(); - }); - }); - - describe('when request has errors', () => { - const mockErrorMessage = 'error message'; - const mockError = { response: { data: { error: mockErrorMessage } } }; - - beforeEach(() => { - addSubscriptionSpy = jest - .spyOn(JiraConnectApi, 'addSubscription') - .mockRejectedValue(mockError); - }); - - it('emits `error` event', async () => { - clickLinkButton(); - - await waitForPromises(); - - expect(reloadPage).not.toHaveBeenCalled(); - expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage); - }); - }); - }); -}); diff --git a/spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js deleted file mode 100644 index d3a9a3bfd41..00000000000 --- a/spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js +++ /dev/null @@ -1,303 +0,0 @@ -import { GlAlert, GlLoadingIcon, GlSearchBoxByType, GlPagination } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { fetchGroups } from '~/jira_connect/subscriptions/api'; -import GroupsList from '~/jira_connect/subscriptions/components/groups_list.vue'; -import GroupsListItem from '~/jira_connect/subscriptions/components/groups_list_item.vue'; -import { DEFAULT_GROUPS_PER_PAGE } from '~/jira_connect/subscriptions/constants'; -import { mockGroup1, mockGroup2 } from '../mock_data'; - -const createMockGroup = (groupId) => { - return { - ...mockGroup1, - id: groupId, - }; -}; - -const createMockGroups = (count) => { - return [...new Array(count)].map((_, idx) => createMockGroup(idx)); -}; - -jest.mock('~/jira_connect/subscriptions/api', () => { - return { - fetchGroups: jest.fn(), - }; -}); - -const mockGroupsPath = '/groups'; - -describe('GroupsList', () => { - let wrapper; - - const mockEmptyResponse = { data: [] }; - - const createComponent = (options = {}) => { - wrapper = extendedWrapper( - shallowMount(GroupsList, { - provide: { - groupsPath: mockGroupsPath, - }, - ...options, - }), - ); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findGlAlert = () => wrapper.findComponent(GlAlert); - const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findAllItems = () => wrapper.findAll(GroupsListItem); - const findFirstItem = () => findAllItems().at(0); - const findSecondItem = () => findAllItems().at(1); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); - const findGroupsList = () => wrapper.findByTestId('groups-list'); - const findPagination = () => wrapper.findComponent(GlPagination); - - describe('when groups are loading', () => { - it('renders loading icon', async () => { - fetchGroups.mockReturnValue(new Promise(() => {})); - createComponent(); - - await wrapper.vm.$nextTick(); - - expect(findGlLoadingIcon().exists()).toBe(true); - }); - }); - - describe('when groups fetch fails', () => { - it('renders error message', async () => { - fetchGroups.mockRejectedValue(); - createComponent(); - - await waitForPromises(); - - expect(findGlLoadingIcon().exists()).toBe(false); - expect(findGlAlert().exists()).toBe(true); - expect(findGlAlert().text()).toBe('Failed to load namespaces. Please try again.'); - }); - }); - - describe('with no groups returned', () => { - it('renders empty state', async () => { - fetchGroups.mockResolvedValue(mockEmptyResponse); - createComponent(); - - await waitForPromises(); - - expect(findGlLoadingIcon().exists()).toBe(false); - expect(wrapper.text()).toContain('No available namespaces'); - }); - }); - - describe('with groups returned', () => { - beforeEach(async () => { - fetchGroups.mockResolvedValue({ - headers: { 'X-PAGE': 1, 'X-TOTAL': 2 }, - data: [mockGroup1, mockGroup2], - }); - createComponent(); - - await waitForPromises(); - }); - - it('renders groups list', () => { - expect(findAllItems()).toHaveLength(2); - expect(findFirstItem().props('group')).toBe(mockGroup1); - expect(findSecondItem().props('group')).toBe(mockGroup2); - }); - - it('sets GroupListItem `disabled` prop to `false`', () => { - findAllItems().wrappers.forEach((groupListItem) => { - expect(groupListItem.props('disabled')).toBe(false); - }); - }); - - it('does not set opacity of the groups list', () => { - expect(findGroupsList().classes()).not.toContain('gl-opacity-5'); - }); - - it('shows error message on $emit from item', async () => { - const errorMessage = 'error message'; - - findFirstItem().vm.$emit('error', errorMessage); - - await wrapper.vm.$nextTick(); - - expect(findGlAlert().exists()).toBe(true); - expect(findGlAlert().text()).toContain(errorMessage); - }); - - describe('when searching groups', () => { - const mockSearchTeam = 'mock search term'; - - describe('while groups are loading', () => { - beforeEach(async () => { - fetchGroups.mockClear(); - fetchGroups.mockReturnValue(new Promise(() => {})); - - findSearchBox().vm.$emit('input', mockSearchTeam); - await wrapper.vm.$nextTick(); - }); - - it('calls `fetchGroups` with search term', () => { - expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { - page: 1, - perPage: DEFAULT_GROUPS_PER_PAGE, - search: mockSearchTeam, - }); - }); - - it('disables GroupListItems', () => { - findAllItems().wrappers.forEach((groupListItem) => { - expect(groupListItem.props('disabled')).toBe(true); - }); - }); - - it('sets opacity of the groups list', () => { - expect(findGroupsList().classes()).toContain('gl-opacity-5'); - }); - - it('sets loading prop of the search box', () => { - expect(findSearchBox().props('isLoading')).toBe(true); - }); - - it('sets value prop of the search box to the search term', () => { - expect(findSearchBox().props('value')).toBe(mockSearchTeam); - }); - }); - - describe('when group search finishes loading', () => { - beforeEach(async () => { - fetchGroups.mockResolvedValue({ data: [mockGroup1] }); - findSearchBox().vm.$emit('input'); - - await waitForPromises(); - }); - - it('renders new groups list', () => { - expect(findAllItems()).toHaveLength(1); - expect(findFirstItem().props('group')).toBe(mockGroup1); - }); - }); - - it.each` - userSearchTerm | finalSearchTerm - ${'gitl'} | ${'gitl'} - ${'git'} | ${'git'} - ${'gi'} | ${''} - ${'g'} | ${''} - ${''} | ${''} - ${undefined} | ${undefined} - `( - 'searches for "$finalSearchTerm" when user enters "$userSearchTerm"', - async ({ userSearchTerm, finalSearchTerm }) => { - fetchGroups.mockResolvedValue({ - data: [mockGroup1], - headers: { 'X-PAGE': 1, 'X-TOTAL': 1 }, - }); - - createComponent(); - await waitForPromises(); - - const searchBox = findSearchBox(); - searchBox.vm.$emit('input', userSearchTerm); - - expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { - page: 1, - perPage: DEFAULT_GROUPS_PER_PAGE, - search: finalSearchTerm, - }); - }, - ); - }); - - describe('when page=2', () => { - beforeEach(async () => { - const totalItems = DEFAULT_GROUPS_PER_PAGE + 1; - const mockGroups = createMockGroups(totalItems); - fetchGroups.mockResolvedValue({ - headers: { 'X-TOTAL': totalItems, 'X-PAGE': 1 }, - data: mockGroups, - }); - createComponent(); - await waitForPromises(); - - const paginationEl = findPagination(); - paginationEl.vm.$emit('input', 2); - }); - - it('should load results for page 2', () => { - expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { - page: 2, - perPage: DEFAULT_GROUPS_PER_PAGE, - search: '', - }); - }); - - it('resets page to 1 on search `input` event', () => { - const mockSearchTerm = 'gitlab'; - const searchBox = findSearchBox(); - - searchBox.vm.$emit('input', mockSearchTerm); - - expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { - page: 1, - perPage: DEFAULT_GROUPS_PER_PAGE, - search: mockSearchTerm, - }); - }); - }); - }); - - describe('pagination', () => { - it.each` - scenario | totalItems | shouldShowPagination - ${'renders pagination'} | ${DEFAULT_GROUPS_PER_PAGE + 1} | ${true} - ${'does not render pagination'} | ${DEFAULT_GROUPS_PER_PAGE} | ${false} - ${'does not render pagination'} | ${2} | ${false} - ${'does not render pagination'} | ${0} | ${false} - `('$scenario with $totalItems groups', async ({ totalItems, shouldShowPagination }) => { - const mockGroups = createMockGroups(totalItems); - fetchGroups.mockResolvedValue({ - headers: { 'X-TOTAL': totalItems, 'X-PAGE': 1 }, - data: mockGroups, - }); - createComponent(); - await waitForPromises(); - - const paginationEl = findPagination(); - - expect(paginationEl.exists()).toBe(shouldShowPagination); - if (shouldShowPagination) { - expect(paginationEl.props('totalItems')).toBe(totalItems); - } - }); - - describe('when `input` event triggered', () => { - beforeEach(async () => { - const MOCK_TOTAL_ITEMS = DEFAULT_GROUPS_PER_PAGE + 1; - fetchGroups.mockResolvedValue({ - headers: { 'X-TOTAL': MOCK_TOTAL_ITEMS, 'X-PAGE': 1 }, - data: createMockGroups(MOCK_TOTAL_ITEMS), - }); - - createComponent(); - await waitForPromises(); - }); - - it('executes `fetchGroups` with correct arguments', () => { - const paginationEl = findPagination(); - paginationEl.vm.$emit('input', 2); - - expect(fetchGroups).toHaveBeenLastCalledWith(mockGroupsPath, { - page: 2, - perPage: DEFAULT_GROUPS_PER_PAGE, - search: '', - }); - }); - }); - }); -}); diff --git a/spec/frontend/jira_connect/subscriptions/components/sign_in_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/sign_in_button_spec.js new file mode 100644 index 00000000000..cb5ae877c47 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/sign_in_button_spec.js @@ -0,0 +1,48 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; +import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +const MOCK_USERS_PATH = '/user'; + +jest.mock('~/jira_connect/subscriptions/utils'); + +describe('SignInButton', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(SignInButton, { + propsData: { + usersPath: MOCK_USERS_PATH, + }, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a button', () => { + createComponent(); + + expect(findButton().exists()).toBe(true); + }); + + describe.each` + expectedHref + ${MOCK_USERS_PATH} + ${`${MOCK_USERS_PATH}?return_to=${encodeURIComponent('https://test.jira.com')}`} + `('when getGitlabSignInURL resolves with `$expectedHref`', ({ expectedHref }) => { + it(`sets button href to ${expectedHref}`, async () => { + getGitlabSignInURL.mockResolvedValue(expectedHref); + createComponent(); + + await waitForPromises(); + + expect(findButton().attributes('href')).toBe(expectedHref); + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js index 32b43765843..4e4a2b58600 100644 --- a/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js @@ -1,12 +1,15 @@ -import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import * as JiraConnectApi from '~/jira_connect/subscriptions/api'; +import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue'; + import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; import createStore from '~/jira_connect/subscriptions/store'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { reloadPage } from '~/jira_connect/subscriptions/utils'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { mockSubscription } from '../mock_data'; jest.mock('~/jira_connect/subscriptions/utils'); @@ -15,11 +18,13 @@ describe('SubscriptionsList', () => { let wrapper; let store; - const createComponent = ({ mountFn = shallowMount, provide = {} } = {}) => { + const createComponent = () => { store = createStore(); - wrapper = mountFn(SubscriptionsList, { - provide, + wrapper = mount(SubscriptionsList, { + provide: { + subscriptions: [mockSubscription], + }, store, }); }; @@ -28,28 +33,28 @@ describe('SubscriptionsList', () => { wrapper.destroy(); }); - const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); - const findGlTable = () => wrapper.findComponent(GlTable); - const findUnlinkButton = () => findGlTable().findComponent(GlButton); + const findUnlinkButton = () => wrapper.findComponent(GlButton); const clickUnlinkButton = () => findUnlinkButton().trigger('click'); describe('template', () => { - it('renders GlEmptyState when subscriptions is empty', () => { + beforeEach(() => { createComponent(); + }); + + it('renders "name" cell correctly', () => { + const groupItemNames = wrapper.findAllComponents(GroupItemName); + expect(groupItemNames.wrappers).toHaveLength(1); - expect(findGlEmptyState().exists()).toBe(true); - expect(findGlTable().exists()).toBe(false); + const item = groupItemNames.at(0); + expect(item.props('group')).toBe(mockSubscription.group); }); - it('renders GlTable when subscriptions are present', () => { - createComponent({ - provide: { - subscriptions: [mockSubscription], - }, - }); + it('renders "created at" cell correctly', () => { + const timeAgoTooltips = wrapper.findAllComponents(TimeagoTooltip); + expect(timeAgoTooltips.wrappers).toHaveLength(1); - expect(findGlEmptyState().exists()).toBe(false); - expect(findGlTable().exists()).toBe(true); + const item = timeAgoTooltips.at(0); + expect(item.props('time')).toBe(mockSubscription.created_at); }); }); @@ -57,12 +62,7 @@ describe('SubscriptionsList', () => { let removeSubscriptionSpy; beforeEach(() => { - createComponent({ - mountFn: mount, - provide: { - subscriptions: [mockSubscription], - }, - }); + createComponent(); removeSubscriptionSpy = jest.spyOn(JiraConnectApi, 'removeSubscription').mockResolvedValue(); }); diff --git a/spec/frontend/jira_connect/subscriptions/index_spec.js b/spec/frontend/jira_connect/subscriptions/index_spec.js index 786f3b4a7d3..b97918a198e 100644 --- a/spec/frontend/jira_connect/subscriptions/index_spec.js +++ b/spec/frontend/jira_connect/subscriptions/index_spec.js @@ -1,24 +1,36 @@ import { initJiraConnect } from '~/jira_connect/subscriptions'; +import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; -jest.mock('~/jira_connect/subscriptions/utils', () => ({ - getLocation: jest.fn().mockResolvedValue('test/location'), -})); +jest.mock('~/jira_connect/subscriptions/utils'); describe('initJiraConnect', () => { - beforeEach(async () => { + const mockInitialHref = 'https://gitlab.com'; + + beforeEach(() => { setFixtures(` - - + + `); - - await initJiraConnect(); }); + const assertSignInLinks = (expectedLink) => { + Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => { + expect(el.getAttribute('href')).toBe(expectedLink); + }); + }; + describe('Sign in links', () => { - it('have `return_to` query parameter', () => { - Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => { - expect(el.href).toContain('return_to=test/location'); - }); + it('are updated on initialization', async () => { + const mockSignInLink = `https://gitlab.com?return_to=${encodeURIComponent('/test/location')}`; + getGitlabSignInURL.mockResolvedValue(mockSignInLink); + + // assert the initial state + assertSignInLinks(mockInitialHref); + + await initJiraConnect(); + + // assert the update has occurred + assertSignInLinks(mockSignInLink); }); }); }); diff --git a/spec/frontend/jira_connect/subscriptions/utils_spec.js b/spec/frontend/jira_connect/subscriptions/utils_spec.js index 2dd95de1b8c..762d9eb3443 100644 --- a/spec/frontend/jira_connect/subscriptions/utils_spec.js +++ b/spec/frontend/jira_connect/subscriptions/utils_spec.js @@ -8,6 +8,7 @@ import { getLocation, reloadPage, sizeToParent, + getGitlabSignInURL, } from '~/jira_connect/subscriptions/utils'; describe('JiraConnect utils', () => { @@ -137,4 +138,25 @@ describe('JiraConnect utils', () => { }); }); }); + + describe('getGitlabSignInURL', () => { + const mockSignInURL = 'https://gitlab.com/sign_in'; + + it.each` + returnTo | expectResult + ${undefined} | ${mockSignInURL} + ${''} | ${mockSignInURL} + ${'/test/location'} | ${`${mockSignInURL}?return_to=${encodeURIComponent('/test/location')}`} + `( + 'returns `$expectResult` when `AP.getLocation` resolves to `$returnTo`', + async ({ returnTo, expectResult }) => { + global.AP = { + getLocation: jest.fn().mockImplementation((cb) => cb(returnTo)), + }; + + const url = await getGitlabSignInURL(mockSignInURL); + expect(url).toBe(expectResult); + }, + ); + }); }); diff --git a/spec/frontend/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js index 7e42ee957d3..a5278af8e33 100644 --- a/spec/frontend/jobs/components/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/manual_variables_form_spec.js @@ -1,9 +1,9 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; -import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import { createLocalVue, mount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import Form from '~/jobs/components/manual_variables_form.vue'; +import ManualVariablesForm from '~/jobs/components/manual_variables_form.vue'; const localVue = createLocalVue(); @@ -21,7 +21,7 @@ describe('Manual Variables Form', () => { }, }; - const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + const createComponent = (props = {}) => { store = new Vuex.Store({ actions: { triggerManualJob: jest.fn(), @@ -29,7 +29,7 @@ describe('Manual Variables Form', () => { }); wrapper = extendedWrapper( - mountFn(localVue.extend(Form), { + mount(localVue.extend(ManualVariablesForm), { propsData: { ...requiredProps, ...props }, localVue, store, @@ -40,88 +40,120 @@ describe('Manual Variables Form', () => { ); }; - const findInputKey = () => wrapper.findComponent({ ref: 'inputKey' }); - const findInputValue = () => wrapper.findComponent({ ref: 'inputSecretValue' }); const findHelpText = () => wrapper.findComponent(GlSprintf); const findHelpLink = () => wrapper.findComponent(GlLink); const findTriggerBtn = () => wrapper.findByTestId('trigger-manual-job-btn'); const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); + const findAllDeleteVarBtns = () => wrapper.findAllByTestId('delete-variable-btn'); + const findDeleteVarBtnPlaceholder = () => wrapper.findByTestId('delete-variable-btn-placeholder'); const findCiVariableKey = () => wrapper.findByTestId('ci-variable-key'); + const findAllCiVariableKeys = () => wrapper.findAllByTestId('ci-variable-key'); const findCiVariableValue = () => wrapper.findByTestId('ci-variable-value'); const findAllVariables = () => wrapper.findAllByTestId('ci-variable-row'); + const setCiVariableKey = () => { + findCiVariableKey().setValue('new key'); + findCiVariableKey().vm.$emit('change'); + nextTick(); + }; + + const setCiVariableKeyByPosition = (position, value) => { + findAllCiVariableKeys().at(position).setValue(value); + findAllCiVariableKeys().at(position).vm.$emit('change'); + nextTick(); + }; + + beforeEach(() => { + createComponent(); + }); + afterEach(() => { wrapper.destroy(); }); - describe('shallowMount', () => { - beforeEach(() => { - createComponent(); - }); + it('creates a new variable when user enters a new key value', async () => { + expect(findAllVariables()).toHaveLength(1); - it('renders empty form with correct placeholders', () => { - expect(findInputKey().attributes('placeholder')).toBe('Input variable key'); - expect(findInputValue().attributes('placeholder')).toBe('Input variable value'); - }); + await setCiVariableKey(); - it('renders help text with provided link', () => { - expect(findHelpText().exists()).toBe(true); - expect(findHelpLink().attributes('href')).toBe( - '/help/ci/variables/index#add-a-cicd-variable-to-a-project', - ); - }); + expect(findAllVariables()).toHaveLength(2); + }); - describe('when adding a new variable', () => { - it('creates a new variable when user types a new key and resets the form', async () => { - await findInputKey().setValue('new key'); + it('does not create extra empty variables', async () => { + expect(findAllVariables()).toHaveLength(1); - expect(findAllVariables()).toHaveLength(1); - expect(findCiVariableKey().element.value).toBe('new key'); - expect(findInputKey().attributes('value')).toBe(undefined); - }); + await setCiVariableKey(); - it('creates a new variable when user types a new value and resets the form', async () => { - await findInputValue().setValue('new value'); + expect(findAllVariables()).toHaveLength(2); - expect(findAllVariables()).toHaveLength(1); - expect(findCiVariableValue().element.value).toBe('new value'); - expect(findInputValue().attributes('value')).toBe(undefined); - }); - }); + await setCiVariableKey(); + + expect(findAllVariables()).toHaveLength(2); }); - describe('mount', () => { - beforeEach(() => { - createComponent({ mountFn: mount }); - }); + it('removes the correct variable row', async () => { + const variableKeyNameOne = 'key-one'; + const variableKeyNameThree = 'key-three'; - describe('when deleting a variable', () => { - it('removes the variable row', async () => { - await wrapper.setData({ - variables: [ - { - key: 'new key', - secret_value: 'value', - id: '1', - }, - ], - }); + await setCiVariableKeyByPosition(0, variableKeyNameOne); - findDeleteVarBtn().trigger('click'); + await setCiVariableKeyByPosition(1, 'key-two'); - await wrapper.vm.$nextTick(); + await setCiVariableKeyByPosition(2, variableKeyNameThree); - expect(findAllVariables()).toHaveLength(0); - }); - }); + expect(findAllVariables()).toHaveLength(4); - it('trigger button is disabled after trigger action', async () => { - expect(findTriggerBtn().props('disabled')).toBe(false); + await findAllDeleteVarBtns().at(1).trigger('click'); - await findTriggerBtn().trigger('click'); + expect(findAllVariables()).toHaveLength(3); - expect(findTriggerBtn().props('disabled')).toBe(true); - }); + expect(findAllCiVariableKeys().at(0).element.value).toBe(variableKeyNameOne); + expect(findAllCiVariableKeys().at(1).element.value).toBe(variableKeyNameThree); + expect(findAllCiVariableKeys().at(2).element.value).toBe(''); + }); + + it('trigger button is disabled after trigger action', async () => { + expect(findTriggerBtn().props('disabled')).toBe(false); + + await findTriggerBtn().trigger('click'); + + expect(findTriggerBtn().props('disabled')).toBe(true); + }); + + it('delete variable button should only show when there is more than one variable', async () => { + expect(findDeleteVarBtn().exists()).toBe(false); + + await setCiVariableKey(); + + expect(findDeleteVarBtn().exists()).toBe(true); + }); + + it('delete variable button placeholder should only exist when a user cannot remove', async () => { + expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); + }); + + it('renders help text with provided link', () => { + expect(findHelpText().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe( + '/help/ci/variables/index#add-a-cicd-variable-to-a-project', + ); + }); + + it('passes variables in correct format', async () => { + jest.spyOn(store, 'dispatch'); + + await setCiVariableKey(); + + await findCiVariableValue().setValue('new value'); + + await findTriggerBtn().trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith('triggerManualJob', [ + { + key: 'new key', + secret_value: 'new value', + }, + ]); }); }); diff --git a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js index 852106db44e..7b604724977 100644 --- a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js +++ b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js @@ -47,107 +47,95 @@ describe('getSuppressNetworkErrorsDuringNavigationLink', () => { subscription = link.request(mockOperation).subscribe(observer); }; - describe('when disabled', () => { - it('returns null', () => { - expect(getSuppressNetworkErrorsDuringNavigationLink()).toBe(null); - }); + it('returns an ApolloLink', () => { + expect(getSuppressNetworkErrorsDuringNavigationLink()).toEqual(expect.any(ApolloLink)); }); - describe('when enabled', () => { - beforeEach(() => { - window.gon = { features: { suppressApolloErrorsDuringNavigation: true } }; - }); - - it('returns an ApolloLink', () => { - expect(getSuppressNetworkErrorsDuringNavigationLink()).toEqual(expect.any(ApolloLink)); - }); - - describe('suppression case', () => { - describe('when navigating away', () => { - beforeEach(() => { - isNavigatingAway.mockReturnValue(true); - }); - - describe('given a network error', () => { - it('does not forward the error', async () => { - const spy = jest.fn(); + describe('suppression case', () => { + describe('when navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(true); + }); - createSubscription(makeMockNetworkErrorLink(), { - next: spy, - error: spy, - complete: spy, - }); + describe('given a network error', () => { + it('does not forward the error', async () => { + const spy = jest.fn(); - // It's hard to test for something _not_ happening. The best we can - // do is wait a bit to make sure nothing happens. - await waitForPromises(); - expect(spy).not.toHaveBeenCalled(); + createSubscription(makeMockNetworkErrorLink(), { + next: spy, + error: spy, + complete: spy, }); + + // It's hard to test for something _not_ happening. The best we can + // do is wait a bit to make sure nothing happens. + await waitForPromises(); + expect(spy).not.toHaveBeenCalled(); }); }); }); + }); - describe('non-suppression cases', () => { - describe('when not navigating away', () => { - beforeEach(() => { - isNavigatingAway.mockReturnValue(false); - }); + describe('non-suppression cases', () => { + describe('when not navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(false); + }); - it('forwards successful requests', (done) => { - createSubscription(makeMockSuccessLink(), { - next({ data }) { - expect(data).toEqual({ foo: { id: 1 } }); - }, - error: () => done.fail('Should not happen'), - complete: () => done(), - }); + it('forwards successful requests', (done) => { + createSubscription(makeMockSuccessLink(), { + next({ data }) { + expect(data).toEqual({ foo: { id: 1 } }); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), }); + }); - it('forwards GraphQL errors', (done) => { - createSubscription(makeMockGraphQLErrorLink(), { - next({ errors }) { - expect(errors).toEqual([{ message: 'foo' }]); - }, - error: () => done.fail('Should not happen'), - complete: () => done(), - }); + it('forwards GraphQL errors', (done) => { + createSubscription(makeMockGraphQLErrorLink(), { + next({ errors }) { + expect(errors).toEqual([{ message: 'foo' }]); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), }); + }); - it('forwards network errors', (done) => { - createSubscription(makeMockNetworkErrorLink(), { - next: () => done.fail('Should not happen'), - error: (error) => { - expect(error.message).toBe('NetworkError'); - done(); - }, - complete: () => done.fail('Should not happen'), - }); + it('forwards network errors', (done) => { + createSubscription(makeMockNetworkErrorLink(), { + next: () => done.fail('Should not happen'), + error: (error) => { + expect(error.message).toBe('NetworkError'); + done(); + }, + complete: () => done.fail('Should not happen'), }); }); + }); - describe('when navigating away', () => { - beforeEach(() => { - isNavigatingAway.mockReturnValue(true); - }); + describe('when navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(true); + }); - it('forwards successful requests', (done) => { - createSubscription(makeMockSuccessLink(), { - next({ data }) { - expect(data).toEqual({ foo: { id: 1 } }); - }, - error: () => done.fail('Should not happen'), - complete: () => done(), - }); + it('forwards successful requests', (done) => { + createSubscription(makeMockSuccessLink(), { + next({ data }) { + expect(data).toEqual({ foo: { id: 1 } }); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), }); + }); - it('forwards GraphQL errors', (done) => { - createSubscription(makeMockGraphQLErrorLink(), { - next({ errors }) { - expect(errors).toEqual([{ message: 'foo' }]); - }, - error: () => done.fail('Should not happen'), - complete: () => done(), - }); + it('forwards GraphQL errors', (done) => { + createSubscription(makeMockGraphQLErrorLink(), { + next({ errors }) { + expect(errors).toEqual([{ message: 'foo' }]); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), }); }); }); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index f5a74ee7f09..de1be5bc337 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -279,6 +279,14 @@ describe('common_utils', () => { top: elementTopWithContext, }); }); + + it('passes through behaviour', () => { + commonUtils.scrollToElementWithContext(`#${id}`, { behavior: 'smooth' }); + expect(window.scrollTo).toHaveBeenCalledWith({ + behavior: 'smooth', + top: elementTopWithContext, + }); + }); }); }); @@ -1000,6 +1008,21 @@ describe('common_utils', () => { }); }); + describe('scopedLabelKey', () => { + it.each` + label | expectedLabelKey + ${undefined} | ${''} + ${''} | ${''} + ${'title'} | ${'title'} + ${'scoped::value'} | ${'scoped'} + ${'scoped::label::value'} | ${'scoped::label'} + ${'scoped::label-some::value'} | ${'scoped::label-some'} + ${'scoped::label::some::value'} | ${'scoped::label::some'} + `('returns "$expectedLabelKey" when label is "$label"', ({ label, expectedLabelKey }) => { + expect(commonUtils.scopedLabelKey({ title: label })).toBe(expectedLabelKey); + }); + }); + describe('getDashPath', () => { it('returns the path following /-/', () => { expect(commonUtils.getDashPath('/some/-/url-with-dashes-/')).toEqual('url-with-dashes-/'); diff --git a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js new file mode 100644 index 00000000000..d19f9352bbc --- /dev/null +++ b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js @@ -0,0 +1,59 @@ +import { GlModal } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import ConfirmModal from '~/lib/utils/confirm_via_gl_modal/confirm_modal.vue'; + +describe('Confirm Modal', () => { + let wrapper; + let modal; + + const createComponent = ({ primaryText, primaryVariant } = {}) => { + wrapper = mount(ConfirmModal, { + propsData: { + primaryText, + primaryVariant, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlModal = () => wrapper.findComponent(GlModal); + + describe('Modal events', () => { + beforeEach(() => { + createComponent(); + modal = findGlModal(); + }); + + it('should emit `confirmed` event on `primary` modal event', () => { + findGlModal().vm.$emit('primary'); + expect(wrapper.emitted('confirmed')).toBeTruthy(); + }); + + it('should emit closed` event on `hidden` modal event', () => { + modal.vm.$emit('hidden'); + expect(wrapper.emitted('closed')).toBeTruthy(); + }); + }); + + describe('Custom properties', () => { + it('should pass correct custom primary text & button variant to the modal when provided', () => { + const primaryText = "Let's do it!"; + const primaryVariant = 'danger'; + + createComponent({ primaryText, primaryVariant }); + const customProps = findGlModal().props('actionPrimary'); + expect(customProps.text).toBe(primaryText); + expect(customProps.attributes.variant).toBe(primaryVariant); + }); + + it('should pass default primary text & button variant to the modal if no custom values provided', () => { + createComponent(); + const customProps = findGlModal().props('actionPrimary'); + expect(customProps.text).toBe('OK'); + expect(customProps.attributes.variant).toBe('confirm'); + }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index f6ad41d5478..7a64b654baa 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -185,15 +185,15 @@ describe('dateInWords', () => { const date = new Date('07/01/2016'); it('should return date in words', () => { - expect(datetimeUtility.dateInWords(date)).toEqual(s__('July 1, 2016')); + expect(datetimeUtility.dateInWords(date)).toEqual(__('July 1, 2016')); }); it('should return abbreviated month name', () => { - expect(datetimeUtility.dateInWords(date, true)).toEqual(s__('Jul 1, 2016')); + expect(datetimeUtility.dateInWords(date, true)).toEqual(__('Jul 1, 2016')); }); it('should return date in words without year', () => { - expect(datetimeUtility.dateInWords(date, true, true)).toEqual(s__('Jul 1')); + expect(datetimeUtility.dateInWords(date, true, true)).toEqual(__('Jul 1')); }); }); @@ -201,11 +201,11 @@ describe('monthInWords', () => { const date = new Date('2017-01-20'); it('returns month name from provided date', () => { - expect(datetimeUtility.monthInWords(date)).toBe(s__('January')); + expect(datetimeUtility.monthInWords(date)).toBe(__('January')); }); it('returns abbreviated month name from provided date', () => { - expect(datetimeUtility.monthInWords(date, true)).toBe(s__('Jan')); + expect(datetimeUtility.monthInWords(date, true)).toBe(__('Jan')); }); }); diff --git a/spec/frontend/lib/utils/file_upload_spec.js b/spec/frontend/lib/utils/file_upload_spec.js index 1dff5d4f925..ff11107ea60 100644 --- a/spec/frontend/lib/utils/file_upload_spec.js +++ b/spec/frontend/lib/utils/file_upload_spec.js @@ -1,4 +1,4 @@ -import fileUpload, { getFilename } from '~/lib/utils/file_upload'; +import fileUpload, { getFilename, validateImageName } from '~/lib/utils/file_upload'; describe('File upload', () => { beforeEach(() => { @@ -64,13 +64,23 @@ describe('File upload', () => { }); describe('getFilename', () => { - it('returns first value correctly', () => { - const event = { - clipboardData: { - getData: () => 'test.png\rtest.txt', - }, - }; - - expect(getFilename(event)).toBe('test.png'); + it('returns file name', () => { + const file = new File([], 'test.jpg'); + + expect(getFilename(file)).toBe('test.jpg'); + }); +}); + +describe('file name validator', () => { + it('validate file name', () => { + const file = new File([], 'test.jpg'); + + expect(validateImageName(file)).toBe('test.jpg'); + }); + + it('illegal file name should be rename to image.png', () => { + const file = new File([], 'test<.png'); + + expect(validateImageName(file)).toBe('image.png'); }); }); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index acbf1a975b8..ab81ec47b64 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -100,11 +100,11 @@ describe('init markdown', () => { text: textArea.value, tag: '```suggestion:-0+0\n{text}\n```', blockTag: true, - selected: '# Does not parse the %br currently.', + selected: '# Does not %br parse the %br currently.', wrap: false, }); - expect(textArea.value).toContain('# Does not parse the \\n currently.'); + expect(textArea.value).toContain('# Does not \\n parse the \\n currently.'); }); it('inserts the tag on the same line if the current line only contains spaces', () => { diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 36e1a453ef4..c6edba19c56 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1060,4 +1060,12 @@ describe('URL utility', () => { }, ); }); + + describe('defaultPromoUrl', () => { + it('Gitlab about page url', () => { + const url = 'https://about.gitlab.com'; + + expect(urlUtils.PROMO_URL).toBe(url); + }); + }); }); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index f42ee295511..218db0b587a 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -39,7 +39,7 @@ export const member = { Developer: 30, Maintainer: 40, Owner: 50, - 'Minimal Access': 5, + 'Minimal access': 5, }, }; diff --git a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap deleted file mode 100644 index 2a8ce1d3f30..00000000000 --- a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap +++ /dev/null @@ -1,43 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AlertWidget Alert firing displays a warning icon and matches snapshot 1`] = ` - - - - - Firing: - alert-label > 42 - - - -`; - -exports[`AlertWidget Alert not firing displays a warning icon and matches snapshot 1`] = ` - - - - - alert-label > 42 - - -`; diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js deleted file mode 100644 index 9bf9e8ad7cc..00000000000 --- a/spec/frontend/monitoring/alert_widget_spec.js +++ /dev/null @@ -1,423 +0,0 @@ -import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import AlertWidget from '~/monitoring/components/alert_widget.vue'; - -const mockReadAlert = jest.fn(); -const mockCreateAlert = jest.fn(); -const mockUpdateAlert = jest.fn(); -const mockDeleteAlert = jest.fn(); - -jest.mock('~/flash'); -jest.mock( - '~/monitoring/services/alerts_service', - () => - function AlertsServiceMock() { - return { - readAlert: mockReadAlert, - createAlert: mockCreateAlert, - updateAlert: mockUpdateAlert, - deleteAlert: mockDeleteAlert, - }; - }, -); - -describe('AlertWidget', () => { - let wrapper; - - const nonFiringAlertResult = [ - { - values: [ - [0, 1], - [1, 42], - [2, 41], - ], - }, - ]; - const firingAlertResult = [ - { - values: [ - [0, 42], - [1, 43], - [2, 44], - ], - }, - ]; - const metricId = '5'; - const alertPath = 'my/alert.json'; - - const relevantQueries = [ - { - metricId, - label: 'alert-label', - alert_path: alertPath, - result: nonFiringAlertResult, - }, - ]; - - const firingRelevantQueries = [ - { - metricId, - label: 'alert-label', - alert_path: alertPath, - result: firingAlertResult, - }, - ]; - - const defaultProps = { - alertsEndpoint: '', - relevantQueries, - alertsToManage: {}, - modalId: 'alert-modal-1', - }; - - const propsWithAlert = { - relevantQueries, - }; - - const propsWithAlertData = { - relevantQueries, - alertsToManage: { - [alertPath]: { operator: '>', threshold: 42, alert_path: alertPath, metricId }, - }, - }; - - const createComponent = (propsData) => { - wrapper = shallowMount(AlertWidget, { - stubs: { GlTooltip, GlSprintf }, - propsData: { - ...defaultProps, - ...propsData, - }, - }); - }; - const hasLoadingIcon = () => wrapper.find(GlLoadingIcon).exists(); - const findWidgetForm = () => wrapper.find({ ref: 'widgetForm' }); - const findAlertErrorMessage = () => wrapper.find({ ref: 'alertErrorMessage' }); - const findCurrentSettingsText = () => - wrapper.find({ ref: 'alertCurrentSetting' }).text().replace(/\s\s+/g, ' '); - const findBadge = () => wrapper.find(GlBadge); - const findTooltip = () => wrapper.find(GlTooltip); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('displays a loading spinner and disables form when fetching alerts', () => { - let resolveReadAlert; - mockReadAlert.mockReturnValue( - new Promise((resolve) => { - resolveReadAlert = resolve; - }), - ); - createComponent(defaultProps); - return wrapper.vm - .$nextTick() - .then(() => { - expect(hasLoadingIcon()).toBe(true); - expect(findWidgetForm().props('disabled')).toBe(true); - - resolveReadAlert({ operator: '==', threshold: 42 }); - }) - .then(() => waitForPromises()) - .then(() => { - expect(hasLoadingIcon()).toBe(false); - expect(findWidgetForm().props('disabled')).toBe(false); - }); - }); - - it('does not render loading spinner if showLoadingState is false', () => { - let resolveReadAlert; - mockReadAlert.mockReturnValue( - new Promise((resolve) => { - resolveReadAlert = resolve; - }), - ); - createComponent({ - ...defaultProps, - showLoadingState: false, - }); - return wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - - resolveReadAlert({ operator: '==', threshold: 42 }); - }) - .then(() => waitForPromises()) - .then(() => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - }); - }); - - it('displays an error message when fetch fails', () => { - mockReadAlert.mockRejectedValue(); - createComponent(propsWithAlert); - expect(hasLoadingIcon()).toBe(true); - - return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalled(); - expect(hasLoadingIcon()).toBe(false); - }); - }); - - describe('Alert not firing', () => { - it('displays a warning icon and matches snapshot', () => { - mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); - createComponent(propsWithAlertData); - - return waitForPromises().then(() => { - expect(findBadge().element).toMatchSnapshot(); - }); - }); - - it('displays an alert summary when there is a single alert', () => { - mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); - createComponent(propsWithAlertData); - return waitForPromises().then(() => { - expect(findCurrentSettingsText()).toEqual('alert-label > 42'); - }); - }); - - it('displays a combined alert summary when there are multiple alerts', () => { - mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); - const propsWithManyAlerts = { - relevantQueries: [ - ...relevantQueries, - ...[ - { - metricId: '6', - alert_path: 'my/alert2.json', - label: 'alert-label2', - result: [{ values: [] }], - }, - ], - ], - alertsToManage: { - 'my/alert.json': { - operator: '>', - threshold: 42, - alert_path: alertPath, - metricId, - }, - 'my/alert2.json': { - operator: '==', - threshold: 900, - alert_path: 'my/alert2.json', - metricId: '6', - }, - }, - }; - createComponent(propsWithManyAlerts); - return waitForPromises().then(() => { - expect(findCurrentSettingsText()).toContain('2 alerts applied'); - }); - }); - }); - - describe('Alert firing', () => { - it('displays a warning icon and matches snapshot', () => { - mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); - propsWithAlertData.relevantQueries = firingRelevantQueries; - createComponent(propsWithAlertData); - - return waitForPromises().then(() => { - expect(findBadge().element).toMatchSnapshot(); - }); - }); - - it('displays an alert summary when there is a single alert', () => { - mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); - propsWithAlertData.relevantQueries = firingRelevantQueries; - createComponent(propsWithAlertData); - return waitForPromises().then(() => { - expect(findCurrentSettingsText()).toEqual('Firing: alert-label > 42'); - }); - }); - - it('displays a combined alert summary when there are multiple alerts', () => { - mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); - const propsWithManyAlerts = { - relevantQueries: [ - ...firingRelevantQueries, - ...[ - { - metricId: '6', - alert_path: 'my/alert2.json', - label: 'alert-label2', - result: [{ values: [] }], - }, - ], - ], - alertsToManage: { - 'my/alert.json': { - operator: '>', - threshold: 42, - alert_path: alertPath, - metricId, - }, - 'my/alert2.json': { - operator: '==', - threshold: 900, - alert_path: 'my/alert2.json', - metricId: '6', - }, - }, - }; - createComponent(propsWithManyAlerts); - - return waitForPromises().then(() => { - expect(findCurrentSettingsText()).toContain('2 alerts applied, 1 firing'); - }); - }); - - it('should display tooltip with thresholds summary', () => { - mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); - const propsWithManyAlerts = { - relevantQueries: [ - ...firingRelevantQueries, - ...[ - { - metricId: '6', - alert_path: 'my/alert2.json', - label: 'alert-label2', - result: [{ values: [] }], - }, - ], - ], - alertsToManage: { - 'my/alert.json': { - operator: '>', - threshold: 42, - alert_path: alertPath, - metricId, - }, - 'my/alert2.json': { - operator: '==', - threshold: 900, - alert_path: 'my/alert2.json', - metricId: '6', - }, - }, - }; - createComponent(propsWithManyAlerts); - - return waitForPromises().then(() => { - expect(findTooltip().text().replace(/\s\s+/g, ' ')).toEqual('Firing: alert-label > 42'); - }); - }); - }); - - it('creates an alert with an appropriate handler', () => { - const alertParams = { - operator: '<', - threshold: 4, - prometheus_metric_id: '5', - }; - mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); - const fakeAlertPath = 'foo/bar'; - mockCreateAlert.mockResolvedValue({ alert_path: fakeAlertPath, ...alertParams }); - createComponent({ - alertsToManage: { - [fakeAlertPath]: { - alert_path: fakeAlertPath, - operator: '<', - threshold: 4, - prometheus_metric_id: '5', - metricId: '5', - }, - }, - }); - - findWidgetForm().vm.$emit('create', alertParams); - - expect(mockCreateAlert).toHaveBeenCalledWith(alertParams); - }); - - it('updates an alert with an appropriate handler', () => { - const alertParams = { operator: '<', threshold: 4, alert_path: alertPath }; - const newAlertParams = { operator: '==', threshold: 12 }; - mockReadAlert.mockResolvedValue(alertParams); - mockUpdateAlert.mockResolvedValue({ ...alertParams, ...newAlertParams }); - createComponent({ - ...propsWithAlertData, - alertsToManage: { - [alertPath]: { - alert_path: alertPath, - operator: '==', - threshold: 12, - metricId: '5', - }, - }, - }); - - findWidgetForm().vm.$emit('update', { - alert: alertPath, - ...newAlertParams, - prometheus_metric_id: '5', - }); - - expect(mockUpdateAlert).toHaveBeenCalledWith(alertPath, newAlertParams); - }); - - it('deletes an alert with an appropriate handler', () => { - const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 }; - mockReadAlert.mockResolvedValue(alertParams); - mockDeleteAlert.mockResolvedValue({}); - createComponent({ - ...propsWithAlert, - alertsToManage: { - [alertPath]: { - alert_path: alertPath, - operator: '>', - threshold: 42, - metricId: '5', - }, - }, - }); - - findWidgetForm().vm.$emit('delete', { alert: alertPath }); - - return wrapper.vm.$nextTick().then(() => { - expect(mockDeleteAlert).toHaveBeenCalledWith(alertPath); - expect(findAlertErrorMessage().exists()).toBe(false); - }); - }); - - describe('when delete fails', () => { - beforeEach(() => { - const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 }; - mockReadAlert.mockResolvedValue(alertParams); - mockDeleteAlert.mockRejectedValue(); - - createComponent({ - ...propsWithAlert, - alertsToManage: { - [alertPath]: { - alert_path: alertPath, - operator: '>', - threshold: 42, - metricId: '5', - }, - }, - }); - - findWidgetForm().vm.$emit('delete', { alert: alertPath }); - return wrapper.vm.$nextTick(); - }); - - it('shows error message', () => { - expect(findAlertErrorMessage().text()).toEqual('Error deleting alert'); - }); - - it('dismisses error message on cancel', () => { - findWidgetForm().vm.$emit('cancel'); - - return wrapper.vm.$nextTick().then(() => { - expect(findAlertErrorMessage().exists()).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 47b6c463377..aaa0a91ffe0 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -8,8 +8,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` metricsdashboardbasepath="/monitoring/monitor-project/-/environments/1/metrics" metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" > - -
    diff --git a/spec/frontend/monitoring/components/alert_widget_form_spec.js b/spec/frontend/monitoring/components/alert_widget_form_spec.js deleted file mode 100644 index e0ef1040f6b..00000000000 --- a/spec/frontend/monitoring/components/alert_widget_form_spec.js +++ /dev/null @@ -1,242 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import INVALID_URL from '~/lib/utils/invalid_url'; -import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue'; -import ModalStub from '../stubs/modal_stub'; - -describe('AlertWidgetForm', () => { - let wrapper; - - const metricId = '8'; - const alertPath = 'alert'; - const relevantQueries = [{ metricId, alert_path: alertPath, label: 'alert-label' }]; - const dataTrackingOptions = { - create: { action: 'click_button', label: 'create_alert' }, - delete: { action: 'click_button', label: 'delete_alert' }, - update: { action: 'click_button', label: 'update_alert' }, - }; - - const defaultProps = { - disabled: false, - relevantQueries, - modalId: 'alert-modal-1', - }; - - const propsWithAlertData = { - ...defaultProps, - alertsToManage: { - alert: { - alert_path: alertPath, - operator: '<', - threshold: 5, - metricId, - runbookUrl: INVALID_URL, - }, - }, - configuredAlert: metricId, - }; - - function createComponent(props = {}) { - const propsData = { - ...defaultProps, - ...props, - }; - - wrapper = shallowMount(AlertWidgetForm, { - propsData, - stubs: { - GlModal: ModalStub, - }, - }); - } - - const modal = () => wrapper.find(ModalStub); - const modalTitle = () => modal().attributes('title'); - const submitButton = () => modal().find(GlLink); - const findRunbookField = () => modal().find('[data-testid="alertRunbookField"]'); - const findThresholdField = () => modal().find('[data-qa-selector="alert_threshold_field"]'); - const submitButtonTrackingOpts = () => - JSON.parse(submitButton().attributes('data-tracking-options')); - const stubEvent = { preventDefault: jest.fn() }; - - afterEach(() => { - if (wrapper) wrapper.destroy(); - }); - - it('disables the form when disabled prop is set', () => { - createComponent({ disabled: true }); - - expect(modal().attributes('ok-disabled')).toBe('true'); - }); - - it('disables the form if no query is selected', () => { - createComponent(); - - expect(modal().attributes('ok-disabled')).toBe('true'); - }); - - it('shows correct title and button text', () => { - createComponent(); - - expect(modalTitle()).toBe('Add alert'); - expect(submitButton().text()).toBe('Add'); - }); - - it('sets tracking options for create alert', () => { - createComponent(); - - expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create); - }); - - it('emits a "create" event when form submitted without existing alert', async () => { - createComponent(defaultProps); - - modal().vm.$emit('shown'); - - findThresholdField().vm.$emit('input', 900); - findRunbookField().vm.$emit('input', INVALID_URL); - - modal().vm.$emit('ok', stubEvent); - - expect(wrapper.emitted().create[0]).toEqual([ - { - alert: undefined, - operator: '>', - threshold: 900, - prometheus_metric_id: '8', - runbookUrl: INVALID_URL, - }, - ]); - }); - - it('resets form when modal is dismissed (hidden)', () => { - createComponent(defaultProps); - - modal().vm.$emit('shown'); - - findThresholdField().vm.$emit('input', 800); - findRunbookField().vm.$emit('input', INVALID_URL); - - modal().vm.$emit('hidden'); - - expect(wrapper.vm.selectedAlert).toEqual({}); - expect(wrapper.vm.operator).toBe(null); - expect(wrapper.vm.threshold).toBe(null); - expect(wrapper.vm.prometheusMetricId).toBe(null); - expect(wrapper.vm.runbookUrl).toBe(null); - }); - - it('sets selectedAlert to the provided configuredAlert on modal show', () => { - createComponent(propsWithAlertData); - - modal().vm.$emit('shown'); - - expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]); - }); - - it('sets selectedAlert to the first relevantQueries if there is only one option on modal show', () => { - createComponent({ - ...propsWithAlertData, - configuredAlert: '', - }); - - modal().vm.$emit('shown'); - - expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]); - }); - - it('does not set selectedAlert to the first relevantQueries if there is more than one option on modal show', () => { - createComponent({ - relevantQueries: [ - { - metricId: '8', - alertPath: 'alert', - label: 'alert-label', - }, - { - metricId: '9', - alertPath: 'alert', - label: 'alert-label', - }, - ], - }); - - modal().vm.$emit('shown'); - - expect(wrapper.vm.selectedAlert).toEqual({}); - }); - - describe('with existing alert', () => { - beforeEach(() => { - createComponent(propsWithAlertData); - - modal().vm.$emit('shown'); - }); - - it('sets tracking options for delete alert', () => { - expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.delete); - }); - - it('updates button text', () => { - expect(modalTitle()).toBe('Edit alert'); - expect(submitButton().text()).toBe('Delete'); - }); - - it('emits "delete" event when form values unchanged', () => { - modal().vm.$emit('ok', stubEvent); - - expect(wrapper.emitted().delete[0]).toEqual([ - { - alert: 'alert', - operator: '<', - threshold: 5, - prometheus_metric_id: '8', - runbookUrl: INVALID_URL, - }, - ]); - }); - }); - - it('emits "update" event when form changed', () => { - const updatedRunbookUrl = `${INVALID_URL}/test`; - - createComponent(propsWithAlertData); - - modal().vm.$emit('shown'); - - findRunbookField().vm.$emit('input', updatedRunbookUrl); - findThresholdField().vm.$emit('input', 11); - - modal().vm.$emit('ok', stubEvent); - - expect(wrapper.emitted().update[0]).toEqual([ - { - alert: 'alert', - operator: '<', - threshold: 11, - prometheus_metric_id: '8', - runbookUrl: updatedRunbookUrl, - }, - ]); - }); - - it('sets tracking options for update alert', async () => { - createComponent(propsWithAlertData); - - modal().vm.$emit('shown'); - - findThresholdField().vm.$emit('input', 11); - - await wrapper.vm.$nextTick(); - - expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update); - }); - - describe('alert runbooks', () => { - it('shows the runbook field', () => { - createComponent(); - - expect(findRunbookField().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js index c44fd8dce33..8dc6132709e 100644 --- a/spec/frontend/monitoring/components/charts/anomaly_spec.js +++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js @@ -159,10 +159,6 @@ describe('Anomaly chart component', () => { const { deploymentData } = getTimeSeriesProps(); expect(deploymentData).toEqual(anomalyDeploymentData); }); - it('"thresholds" keeps the same value', () => { - const { thresholds } = getTimeSeriesProps(); - expect(thresholds).toEqual(inputThresholds); - }); it('"projectPath" keeps the same value', () => { const { projectPath } = getTimeSeriesProps(); expect(projectPath).toEqual(mockProjectPath); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index ea6e4f4a5ed..27f7489aa49 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -643,7 +643,6 @@ describe('Time series component', () => { expect(props.data).toBe(wrapper.vm.chartData); expect(props.option).toBe(wrapper.vm.chartOptions); expect(props.formatTooltipText).toBe(wrapper.vm.formatTooltipText); - expect(props.thresholds).toBe(wrapper.vm.thresholds); }); it('receives a tooltip title', () => { diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js index 8af6075a416..400ac2e8f85 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js @@ -28,7 +28,6 @@ describe('dashboard invalid url parameters', () => { }, }, options, - provide: { hasManagedPrometheus: false }, }); }; diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index c8951dff9ed..9a73dc820af 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -5,7 +5,6 @@ import Vuex from 'vuex'; import { setTestTimeout } from 'helpers/timeout'; import axios from '~/lib/utils/axios_utils'; import invalidUrl from '~/lib/utils/invalid_url'; -import AlertWidget from '~/monitoring/components/alert_widget.vue'; import MonitorAnomalyChart from '~/monitoring/components/charts/anomaly.vue'; import MonitorBarChart from '~/monitoring/components/charts/bar.vue'; @@ -28,7 +27,6 @@ import { barGraphData, } from '../graph_data'; import { - mockAlert, mockLogsHref, mockLogsPath, mockNamespace, @@ -56,7 +54,6 @@ describe('Dashboard Panel', () => { const findCtxMenu = () => wrapper.find({ ref: 'contextualMenu' }); const findMenuItems = () => wrapper.findAll(GlDropdownItem); const findMenuItemByText = (text) => findMenuItems().filter((i) => i.text() === text); - const findAlertsWidget = () => wrapper.find(AlertWidget); const createWrapper = (props, { mountFn = shallowMount, ...options } = {}) => { wrapper = mountFn(DashboardPanel, { @@ -80,9 +77,6 @@ describe('Dashboard Panel', () => { }); }; - const setMetricsSavedToDb = (val) => - monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val); - beforeEach(() => { setTestTimeout(1000); @@ -601,42 +595,6 @@ describe('Dashboard Panel', () => { }); }); - describe('panel alerts', () => { - beforeEach(() => { - mockGetterReturnValue('metricsSavedToDb', []); - - createWrapper(); - }); - - describe.each` - desc | metricsSavedToDb | props | isShown - ${'with permission and no metrics in db'} | ${[]} | ${{}} | ${false} - ${'with permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{}} | ${true} - ${'without permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{ prometheusAlertsAvailable: false }} | ${false} - ${'with permission and unrelated metrics in db'} | ${['another_metric_id']} | ${{}} | ${false} - `('$desc', ({ metricsSavedToDb, isShown, props }) => { - const showsDesc = isShown ? 'shows' : 'does not show'; - - beforeEach(() => { - setMetricsSavedToDb(metricsSavedToDb); - createWrapper({ - alertsEndpoint: '/endpoint', - prometheusAlertsAvailable: true, - ...props, - }); - return wrapper.vm.$nextTick(); - }); - - it(`${showsDesc} alert widget`, () => { - expect(findAlertsWidget().exists()).toBe(isShown); - }); - - it(`${showsDesc} alert configuration`, () => { - expect(findMenuItemByText('Alerts').exists()).toBe(isShown); - }); - }); - }); - describe('When graphData contains links', () => { const findManageLinksItem = () => wrapper.find({ ref: 'manageLinksItem' }); const mockLinks = [ @@ -730,13 +688,6 @@ describe('Dashboard Panel', () => { describe('Runbook url', () => { const findRunbookLinks = () => wrapper.findAll('[data-testid="runbookLink"]'); - const { metricId } = graphData.metrics[0]; - const { alert_path: alertPath } = mockAlert; - - const mockRunbookAlert = { - ...mockAlert, - metricId, - }; beforeEach(() => { mockGetterReturnValue('metricsSavedToDb', []); @@ -747,62 +698,5 @@ describe('Dashboard Panel', () => { expect(findRunbookLinks().length).toBe(0); }); - - describe('when alerts are present', () => { - beforeEach(() => { - setMetricsSavedToDb([metricId]); - - createWrapper({ - alertsEndpoint: '/endpoint', - prometheusAlertsAvailable: true, - }); - }); - - it('does not show a runbook link when a runbook is not set', async () => { - findAlertsWidget().vm.$emit('setAlerts', alertPath, { - ...mockRunbookAlert, - runbookUrl: '', - }); - - await wrapper.vm.$nextTick(); - - expect(findRunbookLinks().length).toBe(0); - }); - - it('shows a runbook link when a runbook is set', async () => { - findAlertsWidget().vm.$emit('setAlerts', alertPath, mockRunbookAlert); - - await wrapper.vm.$nextTick(); - - expect(findRunbookLinks().length).toBe(1); - expect(findRunbookLinks().at(0).attributes('href')).toBe(invalidUrl); - }); - }); - - describe('managed alert deprecation feature flag', () => { - beforeEach(() => { - setMetricsSavedToDb([metricId]); - }); - - it('shows alerts when alerts are not deprecated', () => { - createWrapper( - { alertsEndpoint: '/endpoint', prometheusAlertsAvailable: true }, - { provide: { glFeatures: { managedAlertsDeprecation: false } } }, - ); - - expect(findAlertsWidget().exists()).toBe(true); - expect(findMenuItemByText('Alerts').exists()).toBe(true); - }); - - it('hides alerts when alerts are deprecated', () => { - createWrapper( - { alertsEndpoint: '/endpoint', prometheusAlertsAvailable: true }, - { provide: { glFeatures: { managedAlertsDeprecation: true } } }, - ); - - expect(findAlertsWidget().exists()).toBe(false); - expect(findMenuItemByText('Alerts').exists()).toBe(false); - }); - }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index f899580b3df..9331048bce3 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -46,7 +46,6 @@ describe('Dashboard', () => { stubs: { DashboardHeader, }, - provide: { hasManagedPrometheus: false }, ...options, }); }; @@ -60,9 +59,6 @@ describe('Dashboard', () => { 'dashboard-panel': true, 'dashboard-header': DashboardHeader, }, - provide: { - hasManagedPrometheus: false, - }, ...options, }); }; @@ -412,7 +408,7 @@ describe('Dashboard', () => { }); }); - describe('when all requests have been commited by the store', () => { + describe('when all requests have been committed by the store', () => { beforeEach(() => { store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { currentEnvironmentName: 'production', @@ -460,7 +456,7 @@ describe('Dashboard', () => { it('shows the links section', () => { expect(wrapper.vm.shouldShowLinksSection).toBe(true); - expect(wrapper.find(LinksSection)).toExist(); + expect(wrapper.findComponent(LinksSection).exists()).toBe(true); }); }); @@ -807,29 +803,4 @@ describe('Dashboard', () => { expect(dashboardPanel.exists()).toBe(true); }); }); - - describe('alerts deprecation', () => { - beforeEach(() => { - setupStoreWithData(store); - }); - - const findDeprecationNotice = () => wrapper.findByTestId('alerts-deprecation-warning'); - - it.each` - managedAlertsDeprecation | hasManagedPrometheus | isVisible - ${false} | ${false} | ${false} - ${false} | ${true} | ${true} - ${true} | ${false} | ${false} - ${true} | ${true} | ${false} - `( - 'when the deprecation feature flag is $managedAlertsDeprecation and has managed prometheus is $hasManagedPrometheus', - ({ hasManagedPrometheus, managedAlertsDeprecation, isVisible }) => { - createMountedWrapper( - {}, - { provide: { hasManagedPrometheus, glFeatures: { managedAlertsDeprecation } } }, - ); - expect(findDeprecationNotice().exists()).toBe(isVisible); - }, - ); - }); }); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index bea263f143a..e6785f34597 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -31,7 +31,6 @@ describe('dashboard invalid url parameters', () => { store, stubs: { 'graph-group': true, 'dashboard-panel': true, 'dashboard-header': DashboardHeader }, ...options, - provide: { hasManagedPrometheus: false }, }); }; diff --git a/spec/frontend/monitoring/components/links_section_spec.js b/spec/frontend/monitoring/components/links_section_spec.js index 8fc287c50e4..e37abf6722a 100644 --- a/spec/frontend/monitoring/components/links_section_spec.js +++ b/spec/frontend/monitoring/components/links_section_spec.js @@ -1,5 +1,7 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; + import LinksSection from '~/monitoring/components/links_section.vue'; import { createStore } from '~/monitoring/stores'; @@ -26,12 +28,12 @@ describe('Links Section component', () => { createShallowWrapper(); }); - it('does not render a section if no links are present', () => { + it('does not render a section if no links are present', async () => { setState(); - return wrapper.vm.$nextTick(() => { - expect(findLinks()).not.toExist(); - }); + await nextTick(); + + expect(findLinks().length).toBe(0); }); it('renders a link inside a section', () => { diff --git a/spec/frontend/monitoring/components/variables/text_field_spec.js b/spec/frontend/monitoring/components/variables/text_field_spec.js index 28e02dff4bf..c879803fddd 100644 --- a/spec/frontend/monitoring/components/variables/text_field_spec.js +++ b/spec/frontend/monitoring/components/variables/text_field_spec.js @@ -15,12 +15,12 @@ describe('Text variable component', () => { }); }; - const findInput = () => wrapper.find(GlFormInput); + const findInput = () => wrapper.findComponent(GlFormInput); it('renders a text input when all props are passed', () => { createShallowWrapper(); - expect(findInput()).toExist(); + expect(findInput().exists()).toBe(true); }); it('always has a default value', () => { diff --git a/spec/frontend/monitoring/pages/dashboard_page_spec.js b/spec/frontend/monitoring/pages/dashboard_page_spec.js index dbe9cc21ad5..c5a8b50ee60 100644 --- a/spec/frontend/monitoring/pages/dashboard_page_spec.js +++ b/spec/frontend/monitoring/pages/dashboard_page_spec.js @@ -29,7 +29,7 @@ describe('monitoring/pages/dashboard_page', () => { }); }; - const findDashboardComponent = () => wrapper.find(Dashboard); + const findDashboardComponent = () => wrapper.findComponent(Dashboard); beforeEach(() => { buildRouter(); @@ -60,7 +60,7 @@ describe('monitoring/pages/dashboard_page', () => { smallEmptyState: false, }; - expect(findDashboardComponent()).toExist(); + expect(findDashboardComponent().exists()).toBe(true); expect(allProps).toMatchObject(findDashboardComponent().props()); }); }); diff --git a/spec/frontend/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js index 2a712d4361f..b027d60f61e 100644 --- a/spec/frontend/monitoring/router_spec.js +++ b/spec/frontend/monitoring/router_spec.js @@ -20,8 +20,6 @@ const MockApp = { template: ``, }; -const provide = { hasManagedPrometheus: false }; - describe('Monitoring router', () => { let router; let store; @@ -39,7 +37,6 @@ describe('Monitoring router', () => { localVue, store, router, - provide, }); }; diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js index 9db0f823d84..c454d502beb 100644 --- a/spec/frontend/notes/components/discussion_counter_spec.js +++ b/spec/frontend/notes/components/discussion_counter_spec.js @@ -53,7 +53,7 @@ describe('DiscussionCounter component', () => { describe('has no resolvable discussions', () => { it('does not render', () => { - store.commit(types.SET_INITIAL_DISCUSSIONS, [{ ...discussionMock, resolvable: false }]); + store.commit(types.ADD_OR_UPDATE_DISCUSSIONS, [{ ...discussionMock, resolvable: false }]); store.dispatch('updateResolvableDiscussionsCounts'); wrapper = shallowMount(DiscussionCounter, { store, localVue }); @@ -64,7 +64,7 @@ describe('DiscussionCounter component', () => { describe('has resolvable discussions', () => { const updateStore = (note = {}) => { discussionMock.notes[0] = { ...discussionMock.notes[0], ...note }; - store.commit(types.SET_INITIAL_DISCUSSIONS, [discussionMock]); + store.commit(types.ADD_OR_UPDATE_DISCUSSIONS, [discussionMock]); store.dispatch('updateResolvableDiscussionsCounts'); }; @@ -97,7 +97,7 @@ describe('DiscussionCounter component', () => { let toggleAllButton; const updateStoreWithExpanded = (expanded) => { const discussion = { ...discussionMock, expanded }; - store.commit(types.SET_INITIAL_DISCUSSIONS, [discussion]); + store.commit(types.ADD_OR_UPDATE_DISCUSSIONS, [discussion]); store.dispatch('updateResolvableDiscussionsCounts'); wrapper = shallowMount(DiscussionCounter, { store, localVue }); toggleAllButton = wrapper.find('.toggle-all-discussions-btn'); diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index 59ac75f00e6..ff840a55535 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -1,6 +1,7 @@ import { getByRole } from '@testing-library/dom'; import { shallowMount, mount } from '@vue/test-utils'; import '~/behaviors/markdown/render_gfm'; +import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import NoteableNote from '~/notes/components/noteable_note.vue'; import { SYSTEM_NOTE } from '~/notes/constants'; @@ -26,6 +27,9 @@ describe('DiscussionNotes', () => { const createComponent = (props, mountingMethod = shallowMount) => { wrapper = mountingMethod(DiscussionNotes, { store, + provide: { + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, propsData: { discussion: discussionMock, isExpanded: false, diff --git a/spec/frontend/notes/components/multiline_comment_form_spec.js b/spec/frontend/notes/components/multiline_comment_form_spec.js index b6d603c6358..b027a261c15 100644 --- a/spec/frontend/notes/components/multiline_comment_form_spec.js +++ b/spec/frontend/notes/components/multiline_comment_form_spec.js @@ -50,18 +50,6 @@ describe('MultilineCommentForm', () => { expect(wrapper.vm.commentLineStart).toEqual(lineRange.start); expect(setSelectedCommentPosition).toHaveBeenCalled(); }); - - it('sets commentLineStart to selectedCommentPosition', () => { - const notes = { - selectedCommentPosition: { - start: { ...testLine }, - }, - }; - const wrapper = createWrapper({}, { notes }); - - expect(wrapper.vm.commentLineStart).toEqual(wrapper.vm.selectedCommentPosition.start); - expect(setSelectedCommentPosition).not.toHaveBeenCalled(); - }); }); describe('destroyed', () => { diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js index 40251244423..4e345c9ac8d 100644 --- a/spec/frontend/notes/components/note_body_spec.js +++ b/spec/frontend/notes/components/note_body_spec.js @@ -58,7 +58,6 @@ describe('issue_note_body component', () => { it('adds autosave', () => { const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`; - expect(vm.autosave).toExist(); expect(vm.autosave.key).toEqual(autosaveKey); }); }); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index abc888cd245..48bfd6eac5a 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -1,3 +1,4 @@ +import { GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; @@ -91,6 +92,7 @@ describe('issue_note_form component', () => { expect(conflictWarning.exists()).toBe(true); expect(conflictWarning.text().replace(/\s+/g, ' ').trim()).toBe(message); + expect(conflictWarning.find(GlLink).attributes('href')).toBe('#note_545'); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 727ef02dcbb..6aab60edc4e 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -3,6 +3,7 @@ import { nextTick } from 'vue'; import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; import { trimText } from 'helpers/text_helper'; import mockDiffFile from 'jest/diffs/mock_data/diff_file'; +import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; @@ -31,6 +32,9 @@ describe('noteable_discussion component', () => { wrapper = mount(NoteableDiscussion, { store, + provide: { + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, propsData: { discussion: discussionMock }, }); }); @@ -167,6 +171,9 @@ describe('noteable_discussion component', () => { wrapper = mount(NoteableDiscussion, { store, + provide: { + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, propsData: { discussion: discussionMock }, }); }); @@ -185,6 +192,9 @@ describe('noteable_discussion component', () => { wrapper = mount(NoteableDiscussion, { store, + provide: { + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, propsData: { discussion: discussionMock }, }); }); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index 241a89b2218..b3dbc26878f 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -2,11 +2,14 @@ import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import Vue from 'vue'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { setTestTimeout } from 'helpers/timeout'; +import waitForPromises from 'helpers/wait_for_promises'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import axios from '~/lib/utils/axios_utils'; import * as urlUtility from '~/lib/utils/url_utility'; +import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions'; import CommentForm from '~/notes/components/comment_form.vue'; import NotesApp from '~/notes/components/notes_app.vue'; import * as constants from '~/notes/constants'; @@ -76,6 +79,9 @@ describe('note_app', () => {
    `, }, { + provide: { + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, propsData, store, }, @@ -430,4 +436,57 @@ describe('note_app', () => { ); }); }); + + describe('fetching discussions', () => { + describe('when note anchor is not present', () => { + it('does not include extra query params', async () => { + wrapper = shallowMount(NotesApp, { propsData, store: createStore() }); + await waitForPromises(); + + expect(axiosMock.history.get[0].params).toBeUndefined(); + }); + }); + + describe('when note anchor is present', () => { + const mountWithNotesFilter = (notesFilter) => + shallowMount(NotesApp, { + propsData: { + ...propsData, + notesData: { + ...propsData.notesData, + notesFilter, + }, + }, + store: createStore(), + }); + + beforeEach(() => { + setWindowLocation('#note_1'); + }); + + it('does not include extra query params when filter is undefined', async () => { + wrapper = mountWithNotesFilter(undefined); + await waitForPromises(); + + expect(axiosMock.history.get[0].params).toBeUndefined(); + }); + + it('does not include extra query params when filter is already set to default', async () => { + wrapper = mountWithNotesFilter(constants.DISCUSSION_FILTERS_DEFAULT_VALUE); + await waitForPromises(); + + expect(axiosMock.history.get[0].params).toBeUndefined(); + }); + + it('includes extra query params when filter is not set to default', async () => { + wrapper = mountWithNotesFilter(constants.COMMENTS_ONLY_FILTER_VALUE); + await waitForPromises(); + + expect(axiosMock.history.get[0].params).toEqual({ + notes_filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, + persist_filter: false, + }); + }); + }); + }); }); diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index 6a6e47ffcc5..26a072b82f8 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -1,4 +1,5 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import { setHTMLFixture } from 'helpers/fixtures'; import createEventHub from '~/helpers/event_hub_factory'; @@ -7,12 +8,15 @@ import eventHub from '~/notes/event_hub'; import discussionNavigation from '~/notes/mixins/discussion_navigation'; import notesModule from '~/notes/stores/modules'; +let scrollToFile; const discussion = (id, index) => ({ id, resolvable: index % 2 === 0, active: true, notes: [{}], diff_discussion: true, + position: { new_line: 1, old_line: 1 }, + diff_file: { file_path: 'test.js' }, }); const createDiscussions = () => [...'abcde'].map(discussion); const createComponent = () => ({ @@ -45,6 +49,7 @@ describe('Discussion navigation mixin', () => { jest.spyOn(utils, 'scrollToElement'); expandDiscussion = jest.fn(); + scrollToFile = jest.fn(); const { actions, ...notesRest } = notesModule(); store = new Vuex.Store({ modules: { @@ -52,6 +57,10 @@ describe('Discussion navigation mixin', () => { ...notesRest, actions: { ...actions, expandDiscussion }, }, + diffs: { + namespaced: true, + actions: { scrollToFile }, + }, }, }); store.state.notes.discussions = createDiscussions(); @@ -136,6 +145,7 @@ describe('Discussion navigation mixin', () => { it('scrolls to element', () => { expect(utils.scrollToElement).toHaveBeenCalledWith( findDiscussion('div.discussion', expected), + { behavior: 'smooth' }, ); }); }); @@ -163,6 +173,7 @@ describe('Discussion navigation mixin', () => { expect(utils.scrollToElementWithContext).toHaveBeenCalledWith( findDiscussion('ul.notes', expected), + { behavior: 'smooth' }, ); }); }); @@ -203,10 +214,60 @@ describe('Discussion navigation mixin', () => { it('scrolls to discussion', () => { expect(utils.scrollToElement).toHaveBeenCalledWith( findDiscussion('div.discussion', expected), + { behavior: 'smooth' }, ); }); }); }); }); + + describe.each` + diffsVirtualScrolling + ${false} + ${true} + `('virtual scrolling feature is $diffsVirtualScrolling', ({ diffsVirtualScrolling }) => { + beforeEach(() => { + window.gon = { features: { diffsVirtualScrolling } }; + + jest.spyOn(store, 'dispatch'); + + store.state.notes.currentDiscussionId = 'a'; + window.location.hash = 'test'; + }); + + afterEach(() => { + window.gon = {}; + window.location.hash = ''; + }); + + it('resets location hash if diffsVirtualScrolling flag is true', async () => { + wrapper.vm.jumpToNextDiscussion(); + + await nextTick(); + + expect(window.location.hash).toBe(diffsVirtualScrolling ? '' : '#test'); + }); + + it.each` + tabValue | hashValue + ${'diffs'} | ${false} + ${'show'} | ${!diffsVirtualScrolling} + ${'other'} | ${!diffsVirtualScrolling} + `( + 'calls scrollToFile with setHash as $hashValue when the tab is $tabValue', + async ({ hashValue, tabValue }) => { + window.mrTabs.currentAction = tabValue; + + wrapper.vm.jumpToNextDiscussion(); + + await nextTick(); + + expect(store.dispatch).toHaveBeenCalledWith('diffs/scrollToFile', { + path: 'test.js', + setHash: hashValue, + }); + }, + ); + }); }); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 2ff65d3f47e..bbe074f0105 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -119,7 +119,7 @@ describe('Actions Notes Store', () => { actions.setInitialNotes, [individualNote], { notes: [] }, - [{ type: 'SET_INITIAL_DISCUSSIONS', payload: [individualNote] }], + [{ type: 'ADD_OR_UPDATE_DISCUSSIONS', payload: [individualNote] }], [], done, ); @@ -1395,4 +1395,93 @@ describe('Actions Notes Store', () => { ); }); }); + + describe('fetchDiscussions', () => { + const discussion = { notes: [] }; + + afterEach(() => { + window.gon = {}; + }); + + it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', (done) => { + axiosMock.onAny().reply(200, { discussion }); + testAction( + actions.fetchDiscussions, + {}, + null, + [ + { type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }, + { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, + ], + [{ type: 'updateResolvableDiscussionsCounts' }], + done, + ); + }); + + it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', (done) => { + window.gon = { features: { paginatedIssueDiscussions: true } }; + + testAction( + actions.fetchDiscussions, + { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, + null, + [], + [ + { + type: 'fetchDiscussionsBatch', + payload: { + config: { + params: { notes_filter: 'test-filter', persist_filter: 'test-persist-filter' }, + }, + path: 'test-path', + perPage: 20, + }, + }, + ], + done, + ); + }); + }); + + describe('fetchDiscussionsBatch', () => { + const discussion = { notes: [] }; + + const config = { + params: { notes_filter: 'test-filter', persist_filter: 'test-persist-filter' }, + }; + + const actionPayload = { config, path: 'test-path', perPage: 20 }; + + it('updates the discussions and dispatches `updateResolvableDiscussionsCounts if there are no headers', (done) => { + axiosMock.onAny().reply(200, { discussion }, {}); + testAction( + actions.fetchDiscussionsBatch, + actionPayload, + null, + [ + { type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }, + { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, + ], + [{ type: 'updateResolvableDiscussionsCounts' }], + done, + ); + }); + + it('dispatches itself if there is `x-next-page-cursor` header', (done) => { + axiosMock.onAny().reply(200, { discussion }, { 'x-next-page-cursor': 1 }); + testAction( + actions.fetchDiscussionsBatch, + actionPayload, + null, + [{ type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }], + [ + { + type: 'fetchDiscussionsBatch', + payload: { ...actionPayload, perPage: 30, cursor: 1 }, + }, + ], + done, + ); + }); + }); }); diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index 99e24f724f4..c9e24039b64 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -159,7 +159,7 @@ describe('Notes Store mutations', () => { }); }); - describe('SET_INITIAL_DISCUSSIONS', () => { + describe('ADD_OR_UPDATE_DISCUSSIONS', () => { it('should set the initial notes received', () => { const state = { discussions: [], @@ -169,15 +169,17 @@ describe('Notes Store mutations', () => { individual_note: true, notes: [ { + id: 100, note: '1', }, { + id: 101, note: '2', }, ], }; - mutations.SET_INITIAL_DISCUSSIONS(state, [note, legacyNote]); + mutations.ADD_OR_UPDATE_DISCUSSIONS(state, [note, legacyNote]); expect(state.discussions[0].id).toEqual(note.id); expect(state.discussions[1].notes[0].note).toBe(legacyNote.notes[0].note); @@ -190,7 +192,7 @@ describe('Notes Store mutations', () => { discussions: [], }; - mutations.SET_INITIAL_DISCUSSIONS(state, [ + mutations.ADD_OR_UPDATE_DISCUSSIONS(state, [ { ...note, diff_file: { @@ -208,7 +210,7 @@ describe('Notes Store mutations', () => { discussions: [], }; - mutations.SET_INITIAL_DISCUSSIONS(state, [ + mutations.ADD_OR_UPDATE_DISCUSSIONS(state, [ { ...note, diff_file: { diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap index dbebdeeb452..67e2594d29f 100644 --- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -2,11 +2,11 @@ exports[`packages_list_app renders 1`] = `
    -
    -
    +
    { }; const GlLoadingIcon = { name: 'gl-loading-icon', template: '
    loading
    ' }; - // we need to manually stub dynamic imported components because shallowMount is not able to stub them automatically. See: https://github.com/vuejs/vue-test-utils/issues/1279 - const PackageSearch = { name: 'PackageSearch', template: '
    ' }; - const PackageTitle = { name: 'PackageTitle', template: '
    ' }; - const InfrastructureTitle = { name: 'InfrastructureTitle', template: '
    ' }; - const InfrastructureSearch = { name: 'InfrastructureSearch', template: '
    ' }; - const emptyListHelpUrl = 'helpUrl'; const findEmptyState = () => wrapper.find(GlEmptyState); const findListComponent = () => wrapper.find(PackageList); - const findPackageSearch = () => wrapper.find(PackageSearch); - const findPackageTitle = () => wrapper.find(PackageTitle); - const findInfrastructureTitle = () => wrapper.find(InfrastructureTitle); const findInfrastructureSearch = () => wrapper.find(InfrastructureSearch); const createStore = (filter = []) => { @@ -66,10 +58,6 @@ describe('packages_list_app', () => { PackageList, GlSprintf, GlLink, - PackageSearch, - PackageTitle, - InfrastructureTitle, - InfrastructureSearch, }, provide, }); @@ -191,48 +179,23 @@ describe('packages_list_app', () => { }); }); - describe('Package Search', () => { + describe('Search', () => { it('exists', () => { mountComponent(); - expect(findPackageSearch().exists()).toBe(true); + expect(findInfrastructureSearch().exists()).toBe(true); }); it('on update fetches data from the store', () => { mountComponent(); store.dispatch.mockClear(); - findPackageSearch().vm.$emit('update'); + findInfrastructureSearch().vm.$emit('update'); expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); }); }); - describe('Infrastructure config', () => { - it('defaults to package registry components', () => { - mountComponent(); - - expect(findPackageSearch().exists()).toBe(true); - expect(findPackageTitle().exists()).toBe(true); - - expect(findInfrastructureTitle().exists()).toBe(false); - expect(findInfrastructureSearch().exists()).toBe(false); - }); - - it('mount different component based on the provided values', () => { - mountComponent({ - titleComponent: 'InfrastructureTitle', - searchComponent: 'InfrastructureSearch', - }); - - expect(findPackageSearch().exists()).toBe(false); - expect(findPackageTitle().exists()).toBe(false); - - expect(findInfrastructureTitle().exists()).toBe(true); - expect(findInfrastructureSearch().exists()).toBe(true); - }); - }); - describe('delete alert handling', () => { const originalLocation = window.location.href; const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`; diff --git a/spec/frontend/packages/list/components/packages_search_spec.js b/spec/frontend/packages/list/components/packages_search_spec.js deleted file mode 100644 index 30fad74b493..00000000000 --- a/spec/frontend/packages/list/components/packages_search_spec.js +++ /dev/null @@ -1,128 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import component from '~/packages/list/components/package_search.vue'; -import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue'; -import { sortableFields } from '~/packages/list/utils'; -import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; -import UrlSync from '~/vue_shared/components/url_sync.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('Package Search', () => { - let wrapper; - let store; - - const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); - const findUrlSync = () => wrapper.findComponent(UrlSync); - - const createStore = (isGroupPage) => { - const state = { - config: { - isGroupPage, - }, - sorting: { - orderBy: 'version', - sort: 'desc', - }, - filter: [], - }; - store = new Vuex.Store({ - state, - }); - store.dispatch = jest.fn(); - }; - - const mountComponent = (isGroupPage = false) => { - createStore(isGroupPage); - - wrapper = shallowMount(component, { - localVue, - store, - stubs: { - UrlSync, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('has a registry search component', () => { - mountComponent(); - - expect(findRegistrySearch().exists()).toBe(true); - expect(findRegistrySearch().props()).toMatchObject({ - filter: store.state.filter, - sorting: store.state.sorting, - tokens: expect.arrayContaining([ - expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), - ]), - sortableFields: sortableFields(), - }); - }); - - it.each` - isGroupPage | page - ${false} | ${'project'} - ${true} | ${'group'} - `('in a $page page binds the right props', ({ isGroupPage }) => { - mountComponent(isGroupPage); - - expect(findRegistrySearch().props()).toMatchObject({ - filter: store.state.filter, - sorting: store.state.sorting, - tokens: expect.arrayContaining([ - expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), - ]), - sortableFields: sortableFields(isGroupPage), - }); - }); - - it('on sorting:changed emits update event and calls vuex setSorting', () => { - const payload = { sort: 'foo' }; - - mountComponent(); - - findRegistrySearch().vm.$emit('sorting:changed', payload); - - expect(store.dispatch).toHaveBeenCalledWith('setSorting', payload); - expect(wrapper.emitted('update')).toEqual([[]]); - }); - - it('on filter:changed calls vuex setFilter', () => { - const payload = ['foo']; - - mountComponent(); - - findRegistrySearch().vm.$emit('filter:changed', payload); - - expect(store.dispatch).toHaveBeenCalledWith('setFilter', payload); - }); - - it('on filter:submit emits update event', () => { - mountComponent(); - - findRegistrySearch().vm.$emit('filter:submit'); - - expect(wrapper.emitted('update')).toEqual([[]]); - }); - - it('has a UrlSync component', () => { - mountComponent(); - - expect(findUrlSync().exists()).toBe(true); - }); - - it('on query:changed calls updateQuery from UrlSync', () => { - jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {}); - - mountComponent(); - - findRegistrySearch().vm.$emit('query:changed'); - - expect(UrlSync.methods.updateQuery).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/packages/list/components/packages_title_spec.js b/spec/frontend/packages/list/components/packages_title_spec.js deleted file mode 100644 index a17f72e3133..00000000000 --- a/spec/frontend/packages/list/components/packages_title_spec.js +++ /dev/null @@ -1,71 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list//constants'; -import PackageTitle from '~/packages/list/components/package_title.vue'; -import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; -import TitleArea from '~/vue_shared/components/registry/title_area.vue'; - -describe('PackageTitle', () => { - let wrapper; - let store; - - const findTitleArea = () => wrapper.find(TitleArea); - const findMetadataItem = () => wrapper.find(MetadataItem); - - const mountComponent = (propsData = { helpUrl: 'foo' }) => { - wrapper = shallowMount(PackageTitle, { - store, - propsData, - stubs: { - TitleArea, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('title area', () => { - it('exists', () => { - mountComponent(); - - expect(findTitleArea().exists()).toBe(true); - }); - - it('has the correct props', () => { - mountComponent(); - - expect(findTitleArea().props()).toMatchObject({ - title: LIST_TITLE_TEXT, - infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }], - }); - }); - }); - - describe.each` - count | exist | text - ${null} | ${false} | ${''} - ${undefined} | ${false} | ${''} - ${0} | ${true} | ${'0 Packages'} - ${1} | ${true} | ${'1 Package'} - ${2} | ${true} | ${'2 Packages'} - `('when count is $count metadata item', ({ count, exist, text }) => { - beforeEach(() => { - mountComponent({ count, helpUrl: 'foo' }); - }); - - it(`is ${exist} that it exists`, () => { - expect(findMetadataItem().exists()).toBe(exist); - }); - - if (exist) { - it('has the correct props', () => { - expect(findMetadataItem().props()).toMatchObject({ - icon: 'package', - text, - }); - }); - } - }); -}); diff --git a/spec/frontend/packages/list/components/tokens/package_type_token_spec.js b/spec/frontend/packages/list/components/tokens/package_type_token_spec.js deleted file mode 100644 index b0cbe34f0b9..00000000000 --- a/spec/frontend/packages/list/components/tokens/package_type_token_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/packages/list/components/tokens/package_type_token.vue'; -import { PACKAGE_TYPES } from '~/packages/list/constants'; - -describe('packages_filter', () => { - let wrapper; - - const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); - const findFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); - - const mountComponent = ({ attrs, listeners } = {}) => { - wrapper = shallowMount(component, { - attrs, - listeners, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('it binds all of his attrs to filtered search token', () => { - mountComponent({ attrs: { foo: 'bar' } }); - - expect(findFilteredSearchToken().attributes('foo')).toBe('bar'); - }); - - it('it binds all of his events to filtered search token', () => { - const clickListener = jest.fn(); - mountComponent({ listeners: { click: clickListener } }); - - findFilteredSearchToken().vm.$emit('click'); - - expect(clickListener).toHaveBeenCalled(); - }); - - it.each(PACKAGE_TYPES.map((p, index) => [p, index]))( - 'displays a suggestion for %p', - (packageType, index) => { - mountComponent(); - const item = findFilteredSearchSuggestions().at(index); - expect(item.text()).toBe(packageType.title); - expect(item.props('value')).toBe(packageType.type); - }, - ); -}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap new file mode 100644 index 00000000000..7044c1285d8 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` + +`; + +exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` +
    + + +
    +`; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js new file mode 100644 index 00000000000..6d7bf528495 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js @@ -0,0 +1,73 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import component from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue'; + +describe('delete_button', () => { + let wrapper; + + const defaultProps = { + title: 'Foo title', + tooltipTitle: 'Bar tooltipTitle', + }; + + const findButton = () => wrapper.find(GlButton); + + const mountComponent = (props) => { + wrapper = shallowMount(component, { + propsData: { + ...defaultProps, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('tooltip', () => { + it('the title is controlled by tooltipTitle prop', () => { + mountComponent(); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(defaultProps.tooltipTitle); + }); + + it('is disabled when tooltipTitle is disabled', () => { + mountComponent({ tooltipDisabled: true }); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip.value.disabled).toBe(true); + }); + + describe('button', () => { + it('exists', () => { + mountComponent(); + expect(findButton().exists()).toBe(true); + }); + + it('has the correct props/attributes bound', () => { + mountComponent({ disabled: true }); + expect(findButton().attributes()).toMatchObject({ + 'aria-label': 'Foo title', + icon: 'remove', + title: 'Foo title', + variant: 'danger', + disabled: 'true', + category: 'secondary', + }); + }); + + it('emits a delete event', () => { + mountComponent(); + expect(wrapper.emitted('delete')).toEqual(undefined); + findButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js new file mode 100644 index 00000000000..620c96e8c9e --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js @@ -0,0 +1,152 @@ +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; +import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index'; +import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql'; +import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; + +describe('Delete Image', () => { + let wrapper; + const id = '1'; + const storeMock = { + readQuery: jest.fn().mockReturnValue({ + containerRepository: { + status: 'foo', + }, + }), + writeQuery: jest.fn(), + }; + + const updatePayload = { + data: { + destroyContainerRepository: { + containerRepository: { + status: 'baz', + }, + }, + }, + }; + + const findButton = () => wrapper.find('button'); + + const mountComponent = ({ + propsData = { id }, + mutate = jest.fn().mockResolvedValue({}), + } = {}) => { + wrapper = shallowMount(component, { + propsData, + mocks: { + $apollo: { + mutate, + }, + }, + scopedSlots: { + default: '', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('executes apollo mutate on doDelete', () => { + const mutate = jest.fn().mockResolvedValue({}); + mountComponent({ mutate }); + + wrapper.vm.doDelete(); + + expect(mutate).toHaveBeenCalledWith({ + mutation: deleteContainerRepositoryMutation, + variables: { + id, + }, + update: undefined, + }); + }); + + it('on success emits the correct events', async () => { + const mutate = jest.fn().mockResolvedValue({}); + mountComponent({ mutate }); + + wrapper.vm.doDelete(); + + await waitForPromises(); + + expect(wrapper.emitted('start')).toEqual([[]]); + expect(wrapper.emitted('success')).toEqual([[]]); + expect(wrapper.emitted('end')).toEqual([[]]); + }); + + it('when a payload contains an error emits an error event', async () => { + const mutate = jest + .fn() + .mockResolvedValue({ data: { destroyContainerRepository: { errors: ['foo'] } } }); + + mountComponent({ mutate }); + wrapper.vm.doDelete(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[['foo']]]); + }); + + it('when the api call errors emits an error event', async () => { + const mutate = jest.fn().mockRejectedValue('error'); + + mountComponent({ mutate }); + wrapper.vm.doDelete(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[['error']]]); + }); + + it('uses the update function, when the prop is set to true', () => { + const mutate = jest.fn().mockResolvedValue({}); + + mountComponent({ mutate, propsData: { id, useUpdateFn: true } }); + wrapper.vm.doDelete(); + + expect(mutate).toHaveBeenCalledWith({ + mutation: deleteContainerRepositoryMutation, + variables: { + id, + }, + update: wrapper.vm.updateImageStatus, + }); + }); + + it('updateImage status reads and write to the cache', () => { + mountComponent(); + + const variables = { + id, + first: GRAPHQL_PAGE_SIZE, + }; + + wrapper.vm.updateImageStatus(storeMock, updatePayload); + + expect(storeMock.readQuery).toHaveBeenCalledWith({ + query: getContainerRepositoryDetailsQuery, + variables, + }); + expect(storeMock.writeQuery).toHaveBeenCalledWith({ + query: getContainerRepositoryDetailsQuery, + variables, + data: { + containerRepository: { + status: updatePayload.data.destroyContainerRepository.containerRepository.status, + }, + }, + }); + }); + + it('binds the doDelete function to the default scoped slot', () => { + const mutate = jest.fn().mockResolvedValue({}); + mountComponent({ mutate }); + findButton().trigger('click'); + expect(mutate).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap new file mode 100644 index 00000000000..5f191ef5561 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TagsLoader component has the correct markup 1`] = ` +
    +
    + + + + + + + + + + + + + +
    +
    +`; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js new file mode 100644 index 00000000000..e25162f4da5 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js @@ -0,0 +1,116 @@ +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue'; +import { + DELETE_TAG_SUCCESS_MESSAGE, + DELETE_TAG_ERROR_MESSAGE, + DELETE_TAGS_SUCCESS_MESSAGE, + DELETE_TAGS_ERROR_MESSAGE, + ADMIN_GARBAGE_COLLECTION_TIP, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('Delete alert', () => { + let wrapper; + + const findAlert = () => wrapper.find(GlAlert); + const findLink = () => wrapper.find(GlLink); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { stubs: { GlSprintf }, propsData }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when deleteAlertType is null', () => { + it('does not show the alert', () => { + mountComponent(); + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('when deleteAlertType is not null', () => { + describe('success states', () => { + describe.each` + deleteAlertType | message + ${'success_tag'} | ${DELETE_TAG_SUCCESS_MESSAGE} + ${'success_tags'} | ${DELETE_TAGS_SUCCESS_MESSAGE} + `('when deleteAlertType is $deleteAlertType', ({ deleteAlertType, message }) => { + it('alert exists', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + }); + + describe('when the user is an admin', () => { + beforeEach(() => { + mountComponent({ + deleteAlertType, + isAdmin: true, + garbageCollectionHelpPagePath: 'foo', + }); + }); + + it(`alert title is ${message}`, () => { + expect(findAlert().attributes('title')).toBe(message); + }); + + it('alert body contains admin tip', () => { + expect(findAlert().text()).toMatchInterpolatedText(ADMIN_GARBAGE_COLLECTION_TIP); + }); + + it('alert body contains link', () => { + const alertLink = findLink(); + expect(alertLink.exists()).toBe(true); + expect(alertLink.attributes('href')).toBe('foo'); + }); + }); + + describe('when the user is not an admin', () => { + it('alert exist and text is appropriate', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(message); + }); + }); + }); + }); + describe('error states', () => { + describe.each` + deleteAlertType | message + ${'danger_tag'} | ${DELETE_TAG_ERROR_MESSAGE} + ${'danger_tags'} | ${DELETE_TAGS_ERROR_MESSAGE} + `('when deleteAlertType is $deleteAlertType', ({ deleteAlertType, message }) => { + it('alert exists', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + }); + + describe('when the user is an admin', () => { + it('alert exist and text is appropriate', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(message); + }); + }); + + describe('when the user is not an admin', () => { + it('alert exist and text is appropriate', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(message); + }); + }); + }); + }); + + describe('dismissing alert', () => { + it('GlAlert dismiss event triggers a change event', () => { + mountComponent({ deleteAlertType: 'success_tags' }); + findAlert().vm.$emit('dismiss'); + expect(wrapper.emitted('change')).toEqual([[null]]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js new file mode 100644 index 00000000000..16c9485e69e --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js @@ -0,0 +1,152 @@ +import { GlSprintf, GlFormInput } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue'; +import { + REMOVE_TAG_CONFIRMATION_TEXT, + REMOVE_TAGS_CONFIRMATION_TEXT, + DELETE_IMAGE_CONFIRMATION_TITLE, + DELETE_IMAGE_CONFIRMATION_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import { GlModal } from '../../stubs'; + +describe('Delete Modal', () => { + let wrapper; + + const findModal = () => wrapper.findComponent(GlModal); + const findDescription = () => wrapper.find('[data-testid="description"]'); + const findInputComponent = () => wrapper.findComponent(GlFormInput); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + propsData, + stubs: { + GlSprintf, + GlModal, + }, + }); + }; + + const expectPrimaryActionStatus = (disabled = true) => + expect(findModal().props('actionPrimary')).toMatchObject( + expect.objectContaining({ + attributes: [{ variant: 'danger' }, { disabled }], + }), + ); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('contains a GlModal', () => { + mountComponent(); + expect(findModal().exists()).toBe(true); + }); + + describe('events', () => { + it.each` + glEvent | localEvent + ${'primary'} | ${'confirmDelete'} + ${'cancel'} | ${'cancelDelete'} + `('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => { + mountComponent(); + findModal().vm.$emit(glEvent); + expect(wrapper.emitted(localEvent)).toEqual([[]]); + }); + }); + + describe('methods', () => { + it('show calls gl-modal show', () => { + mountComponent(); + wrapper.vm.show(); + expect(GlModal.methods.show).toHaveBeenCalled(); + }); + }); + + describe('when we are deleting images', () => { + it('has the correct title', () => { + mountComponent({ deleteImage: true }); + + expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TITLE); + }); + + it('has the correct description', () => { + mountComponent({ deleteImage: true }); + + expect(wrapper.text()).toContain( + DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(), + ); + }); + + describe('delete button', () => { + const itemsToBeDeleted = [{ project: { path: 'foo' } }]; + + it('is disabled by default', () => { + mountComponent({ deleteImage: true }); + + expectPrimaryActionStatus(); + }); + + it('if the user types something different from the project path is disabled', async () => { + mountComponent({ deleteImage: true, itemsToBeDeleted }); + + findInputComponent().vm.$emit('input', 'bar'); + + await nextTick(); + + expectPrimaryActionStatus(); + }); + + it('if the user types the project path it is enabled', async () => { + mountComponent({ deleteImage: true, itemsToBeDeleted }); + + findInputComponent().vm.$emit('input', 'foo'); + + await nextTick(); + + expectPrimaryActionStatus(false); + }); + }); + }); + + describe('when we are deleting tags', () => { + it('delete button is enabled', () => { + mountComponent(); + + expectPrimaryActionStatus(false); + }); + + describe('itemsToBeDeleted contains one element', () => { + beforeEach(() => { + mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] }); + }); + + it(`has the correct description`, () => { + expect(findDescription().text()).toBe( + REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'), + ); + }); + + it('has the correct title', () => { + expect(wrapper.text()).toContain('Remove tag'); + }); + }); + + describe('itemsToBeDeleted contains more than element', () => { + beforeEach(() => { + mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] }); + }); + + it(`has the correct description`, () => { + expect(findDescription().text()).toBe( + REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'), + ); + }); + + it('has the correct title', () => { + expect(wrapper.text()).toContain('Remove tags'); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js new file mode 100644 index 00000000000..f06300efa29 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js @@ -0,0 +1,304 @@ +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { GlDropdown } from 'jest/packages_and_registries/container_registry/explorer/stubs'; +import { useFakeDate } from 'helpers/fake_date'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue'; +import { + UNSCHEDULED_STATUS, + SCHEDULED_STATUS, + ONGOING_STATUS, + UNFINISHED_STATUS, + CLEANUP_DISABLED_TEXT, + CLEANUP_DISABLED_TOOLTIP, + CLEANUP_SCHEDULED_TOOLTIP, + CLEANUP_ONGOING_TOOLTIP, + CLEANUP_UNFINISHED_TOOLTIP, + ROOT_IMAGE_TEXT, + ROOT_IMAGE_TOOLTIP, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import getContainerRepositoryTagCountQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { imageTagsCountMock } from '../../mock_data'; + +describe('Details Header', () => { + let wrapper; + let apolloProvider; + let localVue; + + const defaultImage = { + name: 'foo', + updatedAt: '2020-11-03T13:29:21Z', + canDelete: true, + project: { + visibility: 'public', + containerExpirationPolicy: { + enabled: false, + }, + }, + }; + + // set the date to Dec 4, 2020 + useFakeDate(2020, 11, 4); + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + + const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility'); + const findTitle = () => findByTestId('title'); + const findTagsCount = () => findByTestId('tags-count'); + const findCleanup = () => findByTestId('cleanup'); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + const findInfoIcon = () => wrapper.findComponent(GlIcon); + + const waitForMetadataItems = async () => { + // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + }; + + const mountComponent = ({ + propsData = { image: defaultImage }, + resolver = jest.fn().mockResolvedValue(imageTagsCountMock()), + $apollo = undefined, + } = {}) => { + const mocks = {}; + + if ($apollo) { + mocks.$apollo = $apollo; + } else { + localVue = createLocalVue(); + localVue.use(VueApollo); + + const requestHandlers = [[getContainerRepositoryTagCountQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + } + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + mocks, + stubs: { + TitleArea, + GlDropdown, + GlDropdownItem, + }, + }); + }; + + afterEach(() => { + // if we want to mix createMockApollo and manual mocks we need to reset everything + wrapper.destroy(); + apolloProvider = undefined; + localVue = undefined; + wrapper = null; + }); + + describe('image name', () => { + describe('missing image name', () => { + beforeEach(() => { + mountComponent({ propsData: { image: { ...defaultImage, name: '' } } }); + + return waitForPromises(); + }); + + it('root image ', () => { + expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); + }); + + it('has an icon', () => { + expect(findInfoIcon().exists()).toBe(true); + expect(findInfoIcon().props('name')).toBe('information-o'); + }); + + it('has a tooltip', () => { + const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip'); + expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP); + }); + }); + + describe('with image name present', () => { + beforeEach(() => { + mountComponent(); + + return waitForPromises(); + }); + + it('shows image.name ', () => { + expect(findTitle().text()).toContain('foo'); + }); + + it('has no icon', () => { + expect(findInfoIcon().exists()).toBe(false); + }); + }); + }); + + describe('delete button', () => { + it('exists', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findDeleteButton().text()).toBe('Delete image repository'); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findDeleteButton().attributes()).toMatchObject( + expect.objectContaining({ + variant: 'danger', + }), + ); + }); + + it('emits the correct event', () => { + mountComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + + it.each` + canDelete | disabled | isDisabled + ${true} | ${false} | ${undefined} + ${true} | ${true} | ${'true'} + ${false} | ${false} | ${'true'} + ${false} | ${true} | ${'true'} + `( + 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', + ({ canDelete, disabled, isDisabled }) => { + mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); + + expect(findDeleteButton().attributes('disabled')).toBe(isDisabled); + }, + ); + }); + + describe('metadata items', () => { + describe('tags count', () => { + it('displays "-- tags" while loading', async () => { + // here we are forced to mock apollo because `waitForMetadataItems` waits + // for two ticks, de facto allowing the promise to resolve, so there is + // no way to catch the component as both rendered and in loading state + mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } }); + + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('-- tags'); + }); + + it('when there is more than one tag has the correct text', async () => { + mountComponent(); + + await waitForPromises(); + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('13 tags'); + }); + + it('when there is one tag has the correct text', async () => { + mountComponent({ + resolver: jest.fn().mockResolvedValue(imageTagsCountMock({ tagsCount: 1 })), + }); + + await waitForPromises(); + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('1 tag'); + }); + + it('has the correct icon', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findTagsCount().props('icon')).toBe('tag'); + }); + }); + + describe('cleanup metadata item', () => { + it('has the correct icon', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findCleanup().props('icon')).toBe('expire'); + }); + + it('when the expiration policy is disabled', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findCleanup().props()).toMatchObject({ + text: CLEANUP_DISABLED_TEXT, + textTooltip: CLEANUP_DISABLED_TOOLTIP, + }); + }); + + it.each` + status | text | tooltip + ${UNSCHEDULED_STATUS} | ${'Cleanup will run in 1 month'} | ${''} + ${SCHEDULED_STATUS} | ${'Cleanup pending'} | ${CLEANUP_SCHEDULED_TOOLTIP} + ${ONGOING_STATUS} | ${'Cleanup in progress'} | ${CLEANUP_ONGOING_TOOLTIP} + ${UNFINISHED_STATUS} | ${'Cleanup incomplete'} | ${CLEANUP_UNFINISHED_TOOLTIP} + `( + 'when the status is $status the text is $text and the tooltip is $tooltip', + async ({ status, text, tooltip }) => { + mountComponent({ + propsData: { + image: { + ...defaultImage, + expirationPolicyCleanupStatus: status, + project: { + containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + }, + }, + }, + }); + await waitForMetadataItems(); + + expect(findCleanup().props()).toMatchObject({ + text, + textTooltip: tooltip, + }); + }, + ); + }); + + describe('visibility and updated at ', () => { + it('has last updated text', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago'); + }); + + describe('visibility icon', () => { + it('shows an eye when the project is public', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); + }); + it('shows an eye slashed when the project is not public', async () => { + mountComponent({ + propsData: { image: { ...defaultImage, project: { visibility: 'private' } } }, + }); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js new file mode 100644 index 00000000000..f14284e9efe --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js @@ -0,0 +1,54 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; +import { + NO_TAGS_TITLE, + NO_TAGS_MESSAGE, + MISSING_OR_DELETED_IMAGE_TITLE, + MISSING_OR_DELETED_IMAGE_MESSAGE, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('EmptyTagsState component', () => { + let wrapper; + + const findEmptyState = () => wrapper.find(GlEmptyState); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + stubs: { + GlEmptyState, + }, + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('contains gl-empty-state', () => { + mountComponent(); + expect(findEmptyState().exists()).toBe(true); + }); + + it.each` + isEmptyImage | title | description + ${false} | ${NO_TAGS_TITLE} | ${NO_TAGS_MESSAGE} + ${true} | ${MISSING_OR_DELETED_IMAGE_TITLE} | ${MISSING_OR_DELETED_IMAGE_MESSAGE} + `( + 'when isEmptyImage is $isEmptyImage has the correct props', + ({ isEmptyImage, title, description }) => { + mountComponent({ + noContainersImage: 'foo', + isEmptyImage, + }); + + expect(findEmptyState().props()).toMatchObject({ + title, + description, + svgPath: 'foo', + }); + }, + ); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js new file mode 100644 index 00000000000..1a27481a828 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js @@ -0,0 +1,74 @@ +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue'; +import { + DELETE_ALERT_TITLE, + DELETE_ALERT_LINK_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('Partial Cleanup alert', () => { + let wrapper; + + const findAlert = () => wrapper.find(GlAlert); + const findRunLink = () => wrapper.find('[data-testid="run-link"'); + const findHelpLink = () => wrapper.find('[data-testid="help-link"'); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { GlSprintf }, + propsData: { + runCleanupPoliciesHelpPagePath: 'foo', + cleanupPoliciesHelpPagePath: 'bar', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it(`gl-alert has the correct properties`, () => { + mountComponent(); + + expect(findAlert().props()).toMatchObject({ + title: DELETE_ALERT_TITLE, + variant: 'warning', + }); + }); + + it('has the right text', () => { + mountComponent(); + + expect(wrapper.text()).toMatchInterpolatedText(DELETE_ALERT_LINK_TEXT); + }); + + it('contains run link', () => { + mountComponent(); + + const link = findRunLink(); + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: 'foo', + target: '_blank', + }); + }); + + it('contains help link', () => { + mountComponent(); + + const link = findHelpLink(); + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: 'bar', + target: '_blank', + }); + }); + + it('GlAlert dismiss event triggers a dismiss event', () => { + mountComponent(); + + findAlert().vm.$emit('dismiss'); + expect(wrapper.emitted('dismiss')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js new file mode 100644 index 00000000000..a11b102d9a6 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js @@ -0,0 +1,57 @@ +import { GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue'; +import { + DELETE_SCHEDULED, + DELETE_FAILED, + PACKAGE_DELETE_HELP_PAGE_PATH, + SCHEDULED_FOR_DELETION_STATUS_TITLE, + SCHEDULED_FOR_DELETION_STATUS_MESSAGE, + FAILED_DELETION_STATUS_TITLE, + FAILED_DELETION_STATUS_MESSAGE, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('Status Alert', () => { + let wrapper; + + const findLink = () => wrapper.find(GlLink); + const findAlert = () => wrapper.find(GlAlert); + const findMessage = () => wrapper.find('[data-testid="message"]'); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + propsData, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each` + status | title | variant | message | link + ${DELETE_SCHEDULED} | ${SCHEDULED_FOR_DELETION_STATUS_TITLE} | ${'info'} | ${SCHEDULED_FOR_DELETION_STATUS_MESSAGE} | ${PACKAGE_DELETE_HELP_PAGE_PATH} + ${DELETE_FAILED} | ${FAILED_DELETION_STATUS_TITLE} | ${'warning'} | ${FAILED_DELETION_STATUS_MESSAGE} | ${''} + `( + `when the status is $status, title is $title, variant is $variant, message is $message and the link is $link`, + ({ status, title, variant, message, link }) => { + mountComponent({ status }); + + expect(findMessage().text()).toMatchInterpolatedText(message); + expect(findAlert().props()).toMatchObject({ + title, + variant, + }); + if (link) { + expect(findLink().attributes()).toMatchObject({ + target: '_blank', + href: link, + }); + } + }, + ); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js new file mode 100644 index 00000000000..00b1d03b7c2 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js @@ -0,0 +1,382 @@ +import { GlFormCheckbox, GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; + +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue'; +import { + REMOVE_TAG_BUTTON_TITLE, + MISSING_MANIFEST_WARNING_TOOLTIP, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '~/packages_and_registries/container_registry/explorer/constants/index'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { tagsMock } from '../../mock_data'; +import { ListItem } from '../../stubs'; + +describe('tags list row', () => { + let wrapper; + const [tag] = [...tagsMock]; + + const defaultProps = { tag, isMobile: false, index: 0 }; + + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findName = () => wrapper.find('[data-testid="name"]'); + const findSize = () => wrapper.find('[data-testid="size"]'); + const findTime = () => wrapper.find('[data-testid="time"]'); + const findShortRevision = () => wrapper.find('[data-testid="digest"]'); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); + const findDetailsRows = () => wrapper.findAll(DetailsRow); + const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]'); + const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); + const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); + const findWarningIcon = () => wrapper.findComponent(GlIcon); + const findAdditionalActionsMenu = () => wrapper.findComponent(GlDropdown); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + ListItem, + DetailsRow, + GlDropdown, + }, + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('checkbox', () => { + it('exists', () => { + mountComponent(); + + expect(findCheckbox().exists()).toBe(true); + }); + + it("does not exist when the row can't be deleted", () => { + const customTag = { ...tag, canDelete: false }; + + mountComponent({ ...defaultProps, tag: customTag }); + + expect(findCheckbox().exists()).toBe(false); + }); + + it.each` + digest | disabled + ${'foo'} | ${true} + ${null} | ${false} + ${null} | ${true} + ${'foo'} | ${true} + `('is disabled when the digest $digest and disabled is $disabled', ({ digest, disabled }) => { + mountComponent({ tag: { ...tag, digest }, disabled }); + + expect(findCheckbox().attributes('disabled')).toBe('true'); + }); + + it('is wired to the selected prop', () => { + mountComponent({ ...defaultProps, selected: true }); + + expect(findCheckbox().attributes('checked')).toBe('true'); + }); + + it('when changed emit a select event', () => { + mountComponent(); + + findCheckbox().vm.$emit('change'); + + expect(wrapper.emitted('select')).toEqual([[]]); + }); + }); + + describe('tag name', () => { + it('exists', () => { + mountComponent(); + + expect(findName().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findName().text()).toBe(tag.name); + }); + + it('has a tooltip', () => { + mountComponent(); + + const tooltip = getBinding(findName().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(tag.name); + }); + + it('on mobile has mw-s class', () => { + mountComponent({ ...defaultProps, isMobile: true }); + + expect(findName().classes('mw-s')).toBe(true); + }); + }); + + describe('clipboard button', () => { + it('exist if tag.location exist', () => { + mountComponent(); + + expect(findClipboardButton().exists()).toBe(true); + }); + + it('is hidden if tag does not have a location', () => { + mountComponent({ ...defaultProps, tag: { ...tag, location: null } }); + + expect(findClipboardButton().exists()).toBe(false); + }); + + it('has the correct props/attributes', () => { + mountComponent(); + + expect(findClipboardButton().attributes()).toMatchObject({ + text: tag.location, + title: tag.location, + }); + }); + + it('is disabled when the component is disabled', () => { + mountComponent({ ...defaultProps, disabled: true }); + + expect(findClipboardButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('warning icon', () => { + it('is normally hidden', () => { + mountComponent(); + + expect(findWarningIcon().exists()).toBe(false); + }); + + it('is shown when the tag is broken', () => { + mountComponent({ tag: { ...tag, digest: null } }); + + expect(findWarningIcon().exists()).toBe(true); + }); + + it('has an appropriate tooltip', () => { + mountComponent({ tag: { ...tag, digest: null } }); + + const tooltip = getBinding(findWarningIcon().element, 'gl-tooltip'); + expect(tooltip.value.title).toBe(MISSING_MANIFEST_WARNING_TOOLTIP); + }); + }); + + describe('size', () => { + it('exists', () => { + mountComponent(); + + expect(findSize().exists()).toBe(true); + }); + + it('contains the totalSize and layers', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1024', layers: 10 } }); + + expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers'); + }); + + it('when totalSize is giantic', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1099511627776', layers: 2 } }); + + expect(findSize().text()).toMatchInterpolatedText('1024.00 GiB · 2 layers'); + }); + + it('when totalSize is missing', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '0', layers: 10 } }); + + expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`); + }); + + it('when layers are missing', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1024' } }); + + expect(findSize().text()).toMatchInterpolatedText('1.00 KiB'); + }); + + it('when there is 1 layer', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '0', layers: 1 } }); + + expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 1 layer`); + }); + }); + + describe('time', () => { + it('exists', () => { + mountComponent(); + + expect(findTime().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findTime().text()).toBe('Published'); + }); + + it('contains time_ago_tooltip component', () => { + mountComponent(); + + expect(findTimeAgoTooltip().exists()).toBe(true); + }); + + it('pass the correct props to time ago tooltip', () => { + mountComponent(); + + expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.createdAt }); + }); + }); + + describe('digest', () => { + it('exists', () => { + mountComponent(); + + expect(findShortRevision().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 2cf3d2f'); + }); + + it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => { + mountComponent({ tag: { ...tag, digest: null } }); + + expect(findShortRevision().text()).toMatchInterpolatedText(`Digest: ${NOT_AVAILABLE_TEXT}`); + }); + }); + + describe('additional actions menu', () => { + it('exists', () => { + mountComponent(); + + expect(findAdditionalActionsMenu().exists()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findAdditionalActionsMenu().props()).toMatchObject({ + icon: 'ellipsis_v', + text: 'More actions', + textSrOnly: true, + category: 'tertiary', + right: true, + }); + }); + + it.each` + canDelete | digest | disabled | buttonDisabled + ${true} | ${null} | ${true} | ${true} + ${false} | ${'foo'} | ${true} | ${true} + ${false} | ${null} | ${true} | ${true} + ${true} | ${'foo'} | ${true} | ${true} + ${true} | ${'foo'} | ${false} | ${false} + `( + 'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled', + ({ canDelete, digest, disabled, buttonDisabled }) => { + mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled }); + + expect(findAdditionalActionsMenu().props('disabled')).toBe(buttonDisabled); + expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(buttonDisabled); + expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(buttonDisabled); + }, + ); + + describe('delete button', () => { + it('exists and has the correct attrs', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().attributes()).toMatchObject({ + variant: 'danger', + }); + expect(findDeleteButton().text()).toBe(REMOVE_TAG_BUTTON_TITLE); + }); + + it('delete event emits delete', () => { + mountComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + }); + + describe('details rows', () => { + describe('when the tag has a digest', () => { + it('has 3 details rows', async () => { + mountComponent(); + await nextTick(); + + expect(findDetailsRows().length).toBe(3); + }); + + describe.each` + name | finderFunction | text | icon | clipboard + ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 UTC on 2020-11-03'} | ${'clock'} | ${false} + ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} + ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} + `('$name details row', ({ finderFunction, text, icon, clipboard }) => { + it(`has ${text} as text`, async () => { + mountComponent(); + await nextTick(); + + expect(finderFunction().text()).toMatchInterpolatedText(text); + }); + + it(`has the ${icon} icon`, async () => { + mountComponent(); + await nextTick(); + + expect(finderFunction().props('icon')).toBe(icon); + }); + + if (clipboard) { + it(`clipboard button exist`, async () => { + mountComponent(); + await nextTick(); + + expect(finderFunction().find(ClipboardButton).exists()).toBe(clipboard); + }); + + it('is disabled when the component is disabled', async () => { + mountComponent({ ...defaultProps, disabled: true }); + await nextTick(); + + expect(finderFunction().findComponent(ClipboardButton).attributes('disabled')).toBe( + 'true', + ); + }); + } + }); + }); + + describe('when the tag does not have a digest', () => { + it('hides the details rows', async () => { + mountComponent({ tag: { ...tag, digest: null } }); + + await nextTick(); + expect(findDetailsRows().length).toBe(0); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js new file mode 100644 index 00000000000..9a42c82d7e0 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js @@ -0,0 +1,314 @@ +import { GlButton, GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; +import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue'; +import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; +import { + TAGS_LIST_TITLE, + REMOVE_TAGS_BUTTON_TITLE, +} from '~/packages_and_registries/container_registry/explorer/constants/index'; +import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; +import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; + +const localVue = createLocalVue(); + +describe('Tags List', () => { + let wrapper; + let apolloProvider; + const tags = [...tagsMock]; + const readOnlyTags = tags.map((t) => ({ ...t, canDelete: false })); + + const findTagsListRow = () => wrapper.findAll(TagsListRow); + const findDeleteButton = () => wrapper.find(GlButton); + const findListTitle = () => wrapper.find('[data-testid="list-title"]'); + const findPagination = () => wrapper.find(GlKeysetPagination); + const findEmptyState = () => wrapper.find(EmptyTagsState); + const findTagsLoader = () => wrapper.find(TagsLoader); + + const waitForApolloRequestRender = async () => { + await waitForPromises(); + await nextTick(); + }; + + const mountComponent = ({ + propsData = { isMobile: false, id: 1 }, + resolver = jest.fn().mockResolvedValue(imageTagsMock()), + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]]; + + apolloProvider = createMockApollo(requestHandlers); + wrapper = shallowMount(component, { + localVue, + apolloProvider, + propsData, + provide() { + return { + config: {}, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('List title', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findListTitle().exists()).toBe(true); + }); + + it('has the correct text', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findListTitle().text()).toBe(TAGS_LIST_TITLE); + }); + }); + + describe('delete button', () => { + it.each` + inputTags | isMobile | isVisible + ${tags} | ${false} | ${true} + ${tags} | ${true} | ${false} + ${readOnlyTags} | ${false} | ${false} + ${readOnlyTags} | ${true} | ${false} + `( + 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile', + async ({ inputTags, isMobile, isVisible }) => { + mountComponent({ + propsData: { tags: inputTags, isMobile, id: 1 }, + resolver: jest.fn().mockResolvedValue(imageTagsMock(inputTags)), + }); + + await waitForApolloRequestRender(); + + expect(findDeleteButton().exists()).toBe(isVisible); + }, + ); + + it('has the correct text', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE); + }); + + it('has the correct props', async () => { + mountComponent(); + await waitForApolloRequestRender(); + + expect(findDeleteButton().attributes()).toMatchObject({ + category: 'secondary', + variant: 'danger', + }); + }); + + it.each` + disabled | doSelect | buttonDisabled + ${true} | ${false} | ${'true'} + ${true} | ${true} | ${'true'} + ${false} | ${false} | ${'true'} + ${false} | ${true} | ${undefined} + `( + 'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag', + async ({ disabled, buttonDisabled, doSelect }) => { + mountComponent({ propsData: { tags, disabled, isMobile: false, id: 1 } }); + + await waitForApolloRequestRender(); + + if (doSelect) { + findTagsListRow().at(0).vm.$emit('select'); + await nextTick(); + } + + expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled); + }, + ); + + it('click event emits a deleted event with selected items', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsListRow().at(0).vm.$emit('select'); + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); + }); + }); + + describe('list rows', () => { + it('one row exist for each tag', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findTagsListRow()).toHaveLength(tags.length); + }); + + it('the correct props are bound to it', async () => { + mountComponent({ propsData: { disabled: true, id: 1 } }); + + await waitForApolloRequestRender(); + + const rows = findTagsListRow(); + + expect(rows.at(0).attributes()).toMatchObject({ + first: 'true', + disabled: 'true', + }); + }); + + describe('events', () => { + it('select event update the selected items', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsListRow().at(0).vm.$emit('select'); + + await nextTick(); + + expect(findTagsListRow().at(0).attributes('selected')).toBe('true'); + }); + + it('delete event emit a delete event', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsListRow().at(0).vm.$emit('delete'); + expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); + }); + }); + }); + + describe('when the list of tags is empty', () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock([])); + + it('has the empty state', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('does not show the loader', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findTagsLoader().exists()).toBe(false); + }); + + it('does not show the list', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findTagsListRow().exists()).toBe(false); + expect(findListTitle().exists()).toBe(false); + }); + }); + + describe('pagination', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findPagination().exists()).toBe(true); + }); + + it('is hidden when loading', () => { + mountComponent(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is hidden when there are no more pages', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(imageTagsMock([])) }); + + await waitForApolloRequestRender(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is wired to the correct pagination props', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findPagination().props()).toMatchObject({ + hasNextPage: tagsPageInfo.hasNextPage, + hasPreviousPage: tagsPageInfo.hasPreviousPage, + }); + }); + + it('fetch next page when user clicks next', async () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock()); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findPagination().vm.$emit('next'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: tagsPageInfo.endCursor }), + ); + }); + + it('fetch previous page when user clicks prev', async () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock()); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findPagination().vm.$emit('prev'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }), + ); + }); + }); + + describe('loading state', () => { + it.each` + isImageLoading | queryExecuting | loadingVisible + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown', + async ({ isImageLoading, queryExecuting, loadingVisible }) => { + mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } }); + + if (!queryExecuting) { + await waitForApolloRequestRender(); + } + + expect(findTagsLoader().exists()).toBe(loadingVisible); + expect(findTagsListRow().exists()).toBe(!loadingVisible); + expect(findListTitle().exists()).toBe(!loadingVisible); + expect(findPagination().exists()).toBe(!loadingVisible); + }, + ); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js new file mode 100644 index 00000000000..060dc9dc5f3 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; +import { GlSkeletonLoader } from '../../stubs'; + +describe('TagsLoader component', () => { + let wrapper; + + const findGlSkeletonLoaders = () => wrapper.findAll(GlSkeletonLoader); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + GlSkeletonLoader, + }, + // set the repeat to 1 to avoid a long and verbose snapshot + loader: { + ...component.loader, + repeat: 1, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('produces the correct amount of loaders ', () => { + mountComponent(); + expect(findGlSkeletonLoaders().length).toBe(1); + }); + + it('has the correct props', () => { + mountComponent(); + expect(findGlSkeletonLoaders().at(0).props()).toMatchObject({ + width: component.loader.width, + height: component.loader.height, + }); + }); + + it('has the correct markup', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap new file mode 100644 index 00000000000..56579847468 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Registry Group Empty state to match the default snapshot 1`] = ` +
    +

    + With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. + + More Information + +

    +
    +`; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap new file mode 100644 index 00000000000..46b07b4c2d6 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Registry Project Empty state to match the default snapshot 1`] = ` +
    +

    + With the Container Registry, every project can have its own space to store its Docker images. + + More Information + +

    + +
    + CLI Commands +
    + +

    + If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have + + Two-Factor Authentication + + enabled, use a + + Personal Access Token + + instead of a password. +

    + + + + + +

    + + You can add an image to this registry with the following commands: + +

    + + + + + + + + +
    +`; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js new file mode 100644 index 00000000000..e8ddad2d8ca --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js @@ -0,0 +1,87 @@ +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue'; +import { + CLEANUP_TIMED_OUT_ERROR_MESSAGE, + CLEANUP_STATUS_SCHEDULED, + CLEANUP_STATUS_ONGOING, + CLEANUP_STATUS_UNFINISHED, + UNFINISHED_STATUS, + UNSCHEDULED_STATUS, + SCHEDULED_STATUS, + ONGOING_STATUS, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('cleanup_status', () => { + let wrapper; + + const findMainIcon = () => wrapper.findByTestId('main-icon'); + const findExtraInfoIcon = () => wrapper.findByTestId('extra-info'); + + const mountComponent = (propsData = { status: SCHEDULED_STATUS }) => { + wrapper = shallowMountExtended(CleanupStatus, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + status | visible | text + ${UNFINISHED_STATUS} | ${true} | ${CLEANUP_STATUS_UNFINISHED} + ${SCHEDULED_STATUS} | ${true} | ${CLEANUP_STATUS_SCHEDULED} + ${ONGOING_STATUS} | ${true} | ${CLEANUP_STATUS_ONGOING} + ${UNSCHEDULED_STATUS} | ${false} | ${''} + `( + 'when the status is $status is $visible that the component is mounted and has the correct text', + ({ status, visible, text }) => { + mountComponent({ status }); + + expect(findMainIcon().exists()).toBe(visible); + expect(wrapper.text()).toBe(text); + }, + ); + + describe('main icon', () => { + it('exists', () => { + mountComponent(); + + expect(findMainIcon().exists()).toBe(true); + }); + + it(`has the orange class when the status is ${UNFINISHED_STATUS}`, () => { + mountComponent({ status: UNFINISHED_STATUS }); + + expect(findMainIcon().classes('gl-text-orange-500')).toBe(true); + }); + }); + + describe('extra info icon', () => { + it.each` + status | visible + ${UNFINISHED_STATUS} | ${true} + ${SCHEDULED_STATUS} | ${false} + ${ONGOING_STATUS} | ${false} + `( + 'when the status is $status is $visible that the extra icon is visible', + ({ status, visible }) => { + mountComponent({ status }); + + expect(findExtraInfoIcon().exists()).toBe(visible); + }, + ); + + it(`has a tooltip`, () => { + mountComponent({ status: UNFINISHED_STATUS }); + + const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(CLEANUP_TIMED_OUT_ERROR_MESSAGE); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js new file mode 100644 index 00000000000..4039fba869b --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js @@ -0,0 +1,94 @@ +import { GlDropdown } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import QuickstartDropdown from '~/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue'; +import { + QUICK_START, + LOGIN_COMMAND_LABEL, + COPY_LOGIN_TITLE, + BUILD_COMMAND_LABEL, + COPY_BUILD_TITLE, + PUSH_COMMAND_LABEL, + COPY_PUSH_TITLE, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import Tracking from '~/tracking'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +import { dockerCommands } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('cli_commands', () => { + let wrapper; + + const config = { + repositoryUrl: 'foo', + registryHostUrlWithPort: 'bar', + }; + + const findDropdownButton = () => wrapper.find(GlDropdown); + const findCodeInstruction = () => wrapper.findAll(CodeInstruction); + + const mountComponent = () => { + wrapper = mount(QuickstartDropdown, { + localVue, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('shows the correct text on the button', () => { + expect(findDropdownButton().text()).toContain(QUICK_START); + }); + + it('clicking on the dropdown emit a tracking event', () => { + findDropdownButton().vm.$emit('shown'); + expect(Tracking.event).toHaveBeenCalledWith( + undefined, + 'click_dropdown', + expect.objectContaining({ label: 'quickstart_dropdown' }), + ); + }); + + describe.each` + index | labelText | titleText | command | trackedEvent + ${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${dockerCommands.dockerLoginCommand} | ${'click_copy_login'} + ${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${dockerCommands.dockerBuildCommand} | ${'click_copy_build'} + ${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${dockerCommands.dockerPushCommand} | ${'click_copy_push'} + `('code instructions at $index', ({ index, labelText, titleText, command, trackedEvent }) => { + let codeInstruction; + + beforeEach(() => { + codeInstruction = findCodeInstruction().at(index); + }); + + it('exists', () => { + expect(codeInstruction.exists()).toBe(true); + }); + + it(`has the correct props`, () => { + expect(codeInstruction.props()).toMatchObject({ + label: labelText, + instruction: command, + copyText: titleText, + trackingAction: trackedEvent, + trackingLabel: 'quickstart_dropdown', + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js new file mode 100644 index 00000000000..027cdf732bc --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js @@ -0,0 +1,37 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import groupEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue'; +import { GlEmptyState } from '../../stubs'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Registry Group Empty state', () => { + let wrapper; + const config = { + noContainersImage: 'foo', + helpPagePath: 'baz', + }; + + beforeEach(() => { + wrapper = shallowMount(groupEmptyState, { + localVue, + stubs: { + GlEmptyState, + GlSprintf, + }, + provide() { + return { config }; + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('to match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js new file mode 100644 index 00000000000..411bef54e40 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js @@ -0,0 +1,223 @@ +import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import DeleteButton from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue'; +import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue'; +import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue'; +import { + ROW_SCHEDULED_FOR_DELETION, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + IMAGE_DELETE_SCHEDULED_STATUS, + SCHEDULED_STATUS, + ROOT_IMAGE_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { imagesListResponse } from '../../mock_data'; +import { RouterLink } from '../../stubs'; + +describe('Image List Row', () => { + let wrapper; + const [item] = imagesListResponse; + + const findDetailsLink = () => wrapper.find('[data-testid="details-link"]'); + const findTagsCount = () => wrapper.find('[data-testid="tags-count"]'); + const findDeleteBtn = () => wrapper.findComponent(DeleteButton); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findCleanupStatus = () => wrapper.findComponent(CleanupStatus); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findListItemComponent = () => wrapper.findComponent(ListItem); + + const mountComponent = (props) => { + wrapper = shallowMount(Component, { + stubs: { + RouterLink, + GlSprintf, + ListItem, + }, + propsData: { + item, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('list item component', () => { + describe('tooltip', () => { + it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => { + mountComponent(); + + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION); + }); + + it('is disabled when item is being deleted', () => { + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip.value.disabled).toBe(false); + }); + }); + + it('is disabled when the item is in deleting status', () => { + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + + expect(findListItemComponent().props('disabled')).toBe(true); + }); + }); + + describe('image title and path', () => { + it('contains a link to the details page', () => { + mountComponent(); + + const link = findDetailsLink(); + expect(link.text()).toBe(item.path); + expect(findDetailsLink().props('to')).toMatchObject({ + name: 'details', + params: { + id: getIdFromGraphQLId(item.id), + }, + }); + }); + + it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => { + mountComponent({ item: { ...item, name: '' } }); + + expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`); + }); + + it('contains a clipboard button', () => { + mountComponent(); + const button = findClipboardButton(); + expect(button.exists()).toBe(true); + expect(button.props('text')).toBe(item.location); + expect(button.props('title')).toBe(item.location); + }); + + describe('cleanup status component', () => { + it.each` + expirationPolicyCleanupStatus | shown + ${null} | ${false} + ${SCHEDULED_STATUS} | ${true} + `( + 'when expirationPolicyCleanupStatus is $expirationPolicyCleanupStatus it is $shown that the component exists', + ({ expirationPolicyCleanupStatus, shown }) => { + mountComponent({ item: { ...item, expirationPolicyCleanupStatus } }); + + expect(findCleanupStatus().exists()).toBe(shown); + + if (shown) { + expect(findCleanupStatus().props()).toMatchObject({ + status: expirationPolicyCleanupStatus, + }); + } + }, + ); + }); + + describe('when the item is deleting', () => { + beforeEach(() => { + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + }); + + it('the router link is disabled', () => { + // we check the event prop as is the only workaround to disable a router link + expect(findDetailsLink().props('event')).toBe(''); + }); + it('the clipboard button is disabled', () => { + expect(findClipboardButton().attributes('disabled')).toBe('true'); + }); + }); + }); + + describe('delete button', () => { + it('exists', () => { + mountComponent(); + expect(findDeleteBtn().exists()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findDeleteBtn().props()).toMatchObject({ + title: REMOVE_REPOSITORY_LABEL, + tooltipDisabled: item.canDelete, + tooltipTitle: LIST_DELETE_BUTTON_DISABLED, + }); + }); + + it('emits a delete event', () => { + mountComponent(); + + findDeleteBtn().vm.$emit('delete'); + expect(wrapper.emitted('delete')).toEqual([[item]]); + }); + + it.each` + canDelete | status | state + ${false} | ${''} | ${true} + ${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true} + ${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true} + ${true} | ${''} | ${false} + `( + 'disabled is $state when canDelete is $canDelete and status is $status', + ({ canDelete, status, state }) => { + mountComponent({ item: { ...item, canDelete, status } }); + + expect(findDeleteBtn().props('disabled')).toBe(state); + }, + ); + }); + + describe('tags count', () => { + it('exists', () => { + mountComponent(); + expect(findTagsCount().exists()).toBe(true); + }); + + it('contains a tag icon', () => { + mountComponent(); + const icon = findTagsCount().find(GlIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('tag'); + }); + + describe('loading state', () => { + it('shows a loader when metadataLoading is true', () => { + mountComponent({ metadataLoading: true }); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('hides the tags count while loading', () => { + mountComponent({ metadataLoading: true }); + + expect(findTagsCount().exists()).toBe(false); + }); + }); + + describe('tags count text', () => { + it('with one tag in the image', () => { + mountComponent({ item: { ...item, tagsCount: 1 } }); + + expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag'); + }); + it('with more than one tag in the image', () => { + mountComponent({ item: { ...item, tagsCount: 3 } }); + + expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags'); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js new file mode 100644 index 00000000000..e0119954ed4 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js @@ -0,0 +1,88 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue'; +import ImageListRow from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue'; + +import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data'; + +describe('Image List', () => { + let wrapper; + + const findRow = () => wrapper.findAll(ImageListRow); + const findPagination = () => wrapper.find(GlKeysetPagination); + + const mountComponent = (props) => { + wrapper = shallowMount(Component, { + propsData: { + images: imagesListResponse, + pageInfo: defaultPageInfo, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('list', () => { + it('contains one list element for each image', () => { + mountComponent(); + + expect(findRow().length).toBe(imagesListResponse.length); + }); + + it('when delete event is emitted on the row it emits up a delete event', () => { + mountComponent(); + + findRow().at(0).vm.$emit('delete', 'foo'); + expect(wrapper.emitted('delete')).toEqual([['foo']]); + }); + + it('passes down the metadataLoading prop', () => { + mountComponent({ metadataLoading: true }); + expect(findRow().at(0).props('metadataLoading')).toBe(true); + }); + }); + + describe('pagination', () => { + it('exists', () => { + mountComponent(); + + expect(findPagination().exists()).toBe(true); + }); + + it.each` + hasNextPage | hasPreviousPage | isVisible + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + `( + 'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible', + ({ hasNextPage, hasPreviousPage, isVisible }) => { + mountComponent({ pageInfo: { ...defaultPageInfo, hasNextPage, hasPreviousPage } }); + + expect(findPagination().exists()).toBe(isVisible); + expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage); + expect(findPagination().props('hasNextPage')).toBe(hasNextPage); + }, + ); + + it('emits "prev-page" when the user clicks the back page button', () => { + mountComponent(); + + findPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toEqual([[]]); + }); + + it('emits "next-page" when the user clicks the forward page button', () => { + mountComponent(); + + findPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js new file mode 100644 index 00000000000..21748ae2813 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js @@ -0,0 +1,45 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import projectEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue'; +import { dockerCommands } from '../../mock_data'; +import { GlEmptyState } from '../../stubs'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Registry Project Empty state', () => { + let wrapper; + const config = { + repositoryUrl: 'foo', + registryHostUrlWithPort: 'bar', + helpPagePath: 'baz', + twoFactorAuthHelpLink: 'barBaz', + personalAccessTokensHelpLink: 'fooBaz', + noContainersImage: 'bazFoo', + }; + + beforeEach(() => { + wrapper = shallowMount(projectEmptyState, { + localVue, + stubs: { + GlEmptyState, + GlSprintf, + }, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('to match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js new file mode 100644 index 00000000000..92cfeb7633e --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -0,0 +1,135 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue'; +import { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_DISABLED_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +jest.mock('~/lib/utils/datetime_utility', () => ({ + approximateDuration: jest.fn(), + calculateRemainingMilliseconds: jest.fn(), +})); + +describe('registry_header', () => { + let wrapper; + + const findTitleArea = () => wrapper.find(TitleArea); + const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); + const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]'); + const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]'); + + const mountComponent = (propsData, slots) => { + wrapper = shallowMount(Component, { + stubs: { + GlSprintf, + TitleArea, + }, + propsData, + slots, + }); + return wrapper.vm.$nextTick(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('header', () => { + it('has a title', () => { + mountComponent({ metadataLoading: true }); + + expect(findTitleArea().props()).toMatchObject({ + title: CONTAINER_REGISTRY_TITLE, + metadataLoading: true, + }); + }); + + it('has a commands slot', () => { + mountComponent(null, { commands: '
    baz
    ' }); + + expect(findCommandsSlot().text()).toBe('baz'); + }); + + describe('sub header parts', () => { + describe('images count', () => { + it('exists', async () => { + await mountComponent({ imagesCount: 1 }); + + expect(findImagesCountSubHeader().exists()).toBe(true); + }); + + it('when there is one image', async () => { + await mountComponent({ imagesCount: 1 }); + + expect(findImagesCountSubHeader().props()).toMatchObject({ + text: '1 Image repository', + icon: 'container-image', + }); + }); + + it('when there is more than one image', async () => { + await mountComponent({ imagesCount: 3 }); + + expect(findImagesCountSubHeader().props('text')).toBe('3 Image repositories'); + }); + }); + + describe('expiration policy', () => { + it('when is disabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: false }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + }); + + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); + expect(text.props()).toMatchObject({ + text: EXPIRATION_POLICY_DISABLED_TEXT, + icon: 'expire', + size: 'xl', + }); + }); + + it('when is enabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: true }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + }); + + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); + expect(text.props('text')).toBe('Expiration policy will run in '); + }); + it('when the expiration policy is completely disabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: true }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + hideExpirationPolicyData: true, + }); + + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(false); + }); + }); + }); + }); + + describe('info messages', () => { + describe('default message', () => { + it('is correctly bound to title_area props', () => { + mountComponent({ helpPagePath: 'foo' }); + + expect(findTitleArea().props('infoMessages')).toEqual([ + { text: LIST_INTRO_TEXT, link: 'foo' }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js new file mode 100644 index 00000000000..e5a8438f23f --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js @@ -0,0 +1,78 @@ +import { mount } from '@vue/test-utils'; + +import component from '~/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue'; + +describe('Registry Breadcrumb', () => { + let wrapper; + const nameGenerator = jest.fn(); + + const routes = [ + { name: 'list', path: '/', meta: { nameGenerator, root: true } }, + { name: 'details', path: '/:id', meta: { nameGenerator } }, + ]; + + const mountComponent = ($route) => { + wrapper = mount(component, { + mocks: { + $route, + $router: { + options: { + routes, + }, + }, + }, + }); + }; + + beforeEach(() => { + nameGenerator.mockClear(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is rootRoute', () => { + beforeEach(() => { + mountComponent(routes[0]); + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('contains only a single router-link to list', () => { + const links = wrapper.findAll('a'); + + expect(links).toHaveLength(1); + expect(links.at(0).attributes('href')).toBe('/'); + }); + + it('the link text is calculated by nameGenerator', () => { + expect(nameGenerator).toHaveBeenCalledTimes(1); + }); + }); + + describe('when is not rootRoute', () => { + beforeEach(() => { + mountComponent(routes[1]); + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('contains two router-links to list and details', () => { + const links = wrapper.findAll('a'); + + expect(links).toHaveLength(2); + expect(links.at(0).attributes('href')).toBe('/'); + expect(links.at(1).attributes('href')).toBe('#'); + }); + + it('the link text is calculated by nameGenerator', () => { + expect(nameGenerator).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js new file mode 100644 index 00000000000..6a835a28807 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js @@ -0,0 +1,269 @@ +export const imagesListResponse = [ + { + __typename: 'ContainerRepository', + id: 'gid://gitlab/ContainerRepository/26', + name: 'rails-12009', + path: 'gitlab-org/gitlab-test/rails-12009', + status: null, + location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009', + canDelete: true, + createdAt: '2020-11-03T13:29:21Z', + expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', + }, + { + __typename: 'ContainerRepository', + id: 'gid://gitlab/ContainerRepository/11', + name: 'rails-20572', + path: 'gitlab-org/gitlab-test/rails-20572', + status: null, + location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572', + canDelete: true, + createdAt: '2020-09-21T06:57:43Z', + expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', + }, +]; + +export const pageInfo = { + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjI2In0', + endCursor: 'eyJpZCI6IjgifQ', + __typename: 'ContainerRepositoryConnection', +}; + +export const graphQLImageListMock = { + data: { + project: { + __typename: 'Project', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: imagesListResponse, + pageInfo, + }, + }, + }, +}; + +export const graphQLEmptyImageListMock = { + data: { + project: { + __typename: 'Project', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: [], + pageInfo, + }, + }, + }, +}; + +export const graphQLEmptyGroupImageListMock = { + data: { + group: { + __typename: 'Group', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: [], + pageInfo, + }, + }, + }, +}; + +export const deletedContainerRepository = { + id: 'gid://gitlab/ContainerRepository/11', + status: 'DELETE_SCHEDULED', + path: 'gitlab-org/gitlab-test/rails-12009', + __typename: 'ContainerRepository', +}; + +export const graphQLImageDeleteMock = { + data: { + destroyContainerRepository: { + containerRepository: { + ...deletedContainerRepository, + }, + errors: [], + __typename: 'DestroyContainerRepositoryPayload', + }, + }, +}; + +export const graphQLImageDeleteMockError = { + data: { + destroyContainerRepository: { + containerRepository: { + ...deletedContainerRepository, + }, + errors: ['foo'], + __typename: 'DestroyContainerRepositoryPayload', + }, + }, +}; + +export const containerRepositoryMock = { + id: 'gid://gitlab/ContainerRepository/26', + name: 'rails-12009', + path: 'gitlab-org/gitlab-test/rails-12009', + status: null, + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009', + canDelete: true, + createdAt: '2020-11-03T13:29:21Z', + updatedAt: '2020-11-03T13:29:21Z', + expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', + project: { + visibility: 'public', + path: 'gitlab-test', + containerExpirationPolicy: { + enabled: false, + nextRunAt: '2020-11-27T08:59:27Z', + }, + __typename: 'Project', + }, +}; + +export const tagsPageInfo = { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'MQ', + endCursor: 'MTA', +}; + +export const tagsMock = [ + { + digest: 'sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062', + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-24753', + path: 'gitlab-org/gitlab-test/rails-12009:beta-24753', + name: 'beta-24753', + revision: 'c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b', + shortRevision: 'c2613843a', + createdAt: '2020-11-03T13:29:38+00:00', + totalSize: '1099511627776', + canDelete: true, + __typename: 'ContainerRepositoryTag', + }, + { + digest: 'sha256:7f94f97dff89ffd122cafe50cd32329adf682356a7a96f69cbfe313ee589791c', + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-31075', + path: 'gitlab-org/gitlab-test/rails-12009:beta-31075', + name: 'beta-31075', + revision: 'df44e7228f0f255c73e35b6f0699624a615f42746e3e8e2e4b3804a6d6fc3292', + shortRevision: 'df44e7228', + createdAt: '2020-11-03T13:29:32+00:00', + totalSize: '536870912000', + canDelete: true, + __typename: 'ContainerRepositoryTag', + }, +]; + +export const imageTagsMock = (nodes = tagsMock) => ({ + data: { + containerRepository: { + id: containerRepositoryMock.id, + tags: { + nodes, + pageInfo: { ...tagsPageInfo }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', + }, + }, +}); + +export const imageTagsCountMock = (override) => ({ + data: { + containerRepository: { + id: containerRepositoryMock.id, + tagsCount: 13, + ...override, + }, + }, +}); + +export const graphQLImageDetailsMock = (override) => ({ + data: { + containerRepository: { + ...containerRepositoryMock, + + tags: { + nodes: tagsMock, + pageInfo: { ...tagsPageInfo }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', + ...override, + }, + }, +}); + +export const graphQLImageDetailsEmptyTagsMock = { + data: { + containerRepository: { + ...containerRepositoryMock, + tags: { + nodes: [], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', + }, + }, +}; + +export const graphQLDeleteImageRepositoryTagsMock = { + data: { + destroyContainerRepositoryTags: { + deletedTagNames: [], + errors: [], + __typename: 'DestroyContainerRepositoryTagsPayload', + }, + }, +}; + +export const dockerCommands = { + dockerBuildCommand: 'foofoo', + dockerPushCommand: 'barbar', + dockerLoginCommand: 'bazbaz', +}; + +export const graphQLProjectImageRepositoriesDetailsMock = { + data: { + project: { + containerRepositories: { + nodes: [ + { + id: 'gid://gitlab/ContainerRepository/26', + tagsCount: 4, + __typename: 'ContainerRepository', + }, + { + id: 'gid://gitlab/ContainerRepository/11', + tagsCount: 1, + __typename: 'ContainerRepository', + }, + ], + __typename: 'ContainerRepositoryConnection', + }, + __typename: 'Project', + }, + }, +}; + +export const graphQLEmptyImageDetailsMock = { + data: { + containerRepository: null, + }, +}; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js new file mode 100644 index 00000000000..adc9a64e5c9 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -0,0 +1,521 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; +import DeleteAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue'; +import DetailsHeader from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue'; +import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; +import PartialCleanupAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue'; +import StatusAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue'; +import TagsList from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; +import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; + +import { + UNFINISHED_STATUS, + DELETE_SCHEDULED, + ALERT_DANGER_IMAGE, + MISSING_OR_DELETED_IMAGE_BREADCRUMB, + ROOT_IMAGE_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; +import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; + +import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue'; +import Tracking from '~/tracking'; + +import { + graphQLImageDetailsMock, + graphQLDeleteImageRepositoryTagsMock, + containerRepositoryMock, + graphQLEmptyImageDetailsMock, + tagsMock, +} from '../mock_data'; +import { DeleteModal } from '../stubs'; + +const localVue = createLocalVue(); + +describe('Details Page', () => { + let wrapper; + let apolloProvider; + + const findDeleteModal = () => wrapper.find(DeleteModal); + const findPagination = () => wrapper.find(GlKeysetPagination); + const findTagsLoader = () => wrapper.find(TagsLoader); + const findTagsList = () => wrapper.find(TagsList); + const findDeleteAlert = () => wrapper.find(DeleteAlert); + const findDetailsHeader = () => wrapper.find(DetailsHeader); + const findEmptyState = () => wrapper.find(EmptyTagsState); + const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); + const findStatusAlert = () => wrapper.find(StatusAlert); + const findDeleteImage = () => wrapper.find(DeleteImage); + + const routeId = 1; + + const breadCrumbState = { + updateName: jest.fn(), + }; + + const cleanTags = tagsMock.map((t) => { + const result = { ...t }; + // eslint-disable-next-line no-underscore-dangle + delete result.__typename; + return result; + }); + + const waitForApolloRequestRender = async () => { + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + + const mountComponent = ({ + resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), + options, + config = {}, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [getContainerRepositoryDetailsQuery, resolver], + [deleteContainerRepositoryTagsMutation, mutationResolver], + ]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + stubs: { + DeleteModal, + DeleteImage, + }, + mocks: { + $route: { + params: { + id: routeId, + }, + }, + }, + provide() { + return { + breadCrumbState, + config, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when isLoading is true', () => { + it('shows the loader', () => { + mountComponent(); + + expect(findTagsLoader().exists()).toBe(true); + }); + + it('does not show the list', () => { + mountComponent(); + + expect(findTagsList().exists()).toBe(false); + }); + }); + + describe('when the image does not exist', () => { + it('does not show the default ui', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(findTagsLoader().exists()).toBe(false); + expect(findDetailsHeader().exists()).toBe(false); + expect(findTagsList().exists()).toBe(false); + expect(findPagination().exists()).toBe(false); + }); + + it('shows an empty state message', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('list', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findTagsList().exists()).toBe(true); + }); + + it('has the correct props bound', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findTagsList().props()).toMatchObject({ + isMobile: false, + }); + }); + + describe('deleteEvent', () => { + describe('single item', () => { + let tagToBeDeleted; + beforeEach(async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + [tagToBeDeleted] = cleanTags; + findTagsList().vm.$emit('delete', [tagToBeDeleted]); + }); + + it('open the modal', async () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); + + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'registry_tag_delete', + }); + }); + }); + + describe('multiple items', () => { + beforeEach(async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsList().vm.$emit('delete', cleanTags); + }); + + it('open the modal', () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); + + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'bulk_registry_tag_delete', + }); + }); + }); + }); + }); + + describe('modal', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findDeleteModal().exists()).toBe(true); + }); + + describe('cancel event', () => { + it('tracks cancel_delete', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findDeleteModal().vm.$emit('cancel'); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', { + label: 'registry_tag_delete', + }); + }); + }); + + describe('confirmDelete event', () => { + let mutationResolver; + + beforeEach(() => { + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); + mountComponent({ mutationResolver }); + + return waitForApolloRequestRender(); + }); + describe('when one item is selected to be deleted', () => { + it('calls apollo mutation with the right parameters', async () => { + findTagsList().vm.$emit('delete', [cleanTags[0]]); + + await wrapper.vm.$nextTick(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: [cleanTags[0].name] }), + ); + }); + }); + + describe('when more than one item is selected to be deleted', () => { + it('calls apollo mutation with the right parameters', async () => { + findTagsList().vm.$emit('delete', tagsMock); + + await wrapper.vm.$nextTick(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }), + ); + }); + }); + }); + }); + + describe('Header', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDetailsHeader().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDetailsHeader().props()).toMatchObject({ + image: { + name: containerRepositoryMock.name, + project: { + visibility: containerRepositoryMock.project.visibility, + }, + }, + }); + }); + }); + + describe('Delete Alert', () => { + const config = { + isAdmin: true, + garbageCollectionHelpPagePath: 'baz', + }; + const deleteAlertType = 'success_tag'; + + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDeleteAlert().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent({ + options: { + data: () => ({ + deleteAlertType, + }), + }, + config, + }); + + await waitForApolloRequestRender(); + + expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType }); + }); + }); + + describe('Partial Cleanup Alert', () => { + const config = { + runCleanupPoliciesHelpPagePath: 'foo', + expirationPolicyHelpPagePath: 'bar', + userCalloutsPath: 'call_out_path', + userCalloutId: 'call_out_id', + showUnfinishedTagCleanupCallout: true, + }; + + describe(`when expirationPolicyCleanupStatus is ${UNFINISHED_STATUS}`, () => { + let resolver; + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue( + graphQLImageDetailsMock({ + expirationPolicyCleanupStatus: UNFINISHED_STATUS, + }), + ); + }); + + it('exists', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().props()).toEqual({ + runCleanupPoliciesHelpPagePath: config.runCleanupPoliciesHelpPagePath, + cleanupPoliciesHelpPagePath: config.expirationPolicyHelpPagePath, + }); + }); + + it('dismiss hides the component', async () => { + jest.spyOn(axios, 'post').mockReturnValue(); + + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(true); + + findPartialCleanupAlert().vm.$emit('dismiss'); + + await wrapper.vm.$nextTick(); + + expect(axios.post).toHaveBeenCalledWith(config.userCalloutsPath, { + feature_name: config.userCalloutId, + }); + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + + it('is hidden if the callout is dismissed', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + }); + + describe(`when expirationPolicyCleanupStatus is not ${UNFINISHED_STATUS}`, () => { + it('the component is hidden', async () => { + mountComponent({ config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + }); + }); + + describe('Breadcrumb connection', () => { + it('when the details are fetched updates the name', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); + }); + + it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB); + }); + + it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => { + mountComponent({ + resolver: jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })), + }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT); + }); + }); + + describe('when the image has a status different from null', () => { + const resolver = jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ status: DELETE_SCHEDULED })); + it('disables all the actions', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findDetailsHeader().props('disabled')).toBe(true); + expect(findTagsList().props('disabled')).toBe(true); + }); + + it('shows a status alert', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findStatusAlert().exists()).toBe(true); + expect(findStatusAlert().props()).toMatchObject({ + status: DELETE_SCHEDULED, + }); + }); + }); + + describe('delete the image', () => { + const mountComponentAndDeleteImage = async () => { + mountComponent(); + + await waitForApolloRequestRender(); + findDetailsHeader().vm.$emit('delete'); + + await wrapper.vm.$nextTick(); + }; + + it('on delete event it deletes the image', async () => { + await mountComponentAndDeleteImage(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(findDeleteImage().emitted('start')).toEqual([[]]); + }); + + it('binds the correct props to the modal', async () => { + await mountComponentAndDeleteImage(); + + expect(findDeleteModal().props()).toMatchObject({ + itemsToBeDeleted: [{ path: 'gitlab-org/gitlab-test/rails-12009' }], + deleteImage: true, + }); + }); + + it('binds correctly to delete-image start and end events', async () => { + mountComponent(); + + findDeleteImage().vm.$emit('start'); + + await wrapper.vm.$nextTick(); + + expect(findTagsLoader().exists()).toBe(true); + + findDeleteImage().vm.$emit('end'); + + await wrapper.vm.$nextTick(); + + expect(findTagsLoader().exists()).toBe(false); + }); + + it('binds correctly to delete-image error event', async () => { + mountComponent(); + + findDeleteImage().vm.$emit('error'); + + await wrapper.vm.$nextTick(); + + expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js new file mode 100644 index 00000000000..5f4cb8969bc --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js @@ -0,0 +1,24 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/pages/index.vue'; + +describe('List Page', () => { + let wrapper; + + const findRouterView = () => wrapper.find({ ref: 'router-view' }); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + RouterView: true, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + it('has a router view', () => { + expect(findRouterView().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js new file mode 100644 index 00000000000..051d1e2a169 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js @@ -0,0 +1,597 @@ +import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; +import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; +import CliCommands from '~/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue'; +import GroupEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue'; +import ImageList from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue'; +import ProjectEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue'; +import RegistryHeader from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue'; +import { + DELETE_IMAGE_SUCCESS_MESSAGE, + DELETE_IMAGE_ERROR_MESSAGE, + SORT_FIELDS, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql'; +import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql'; +import component from '~/packages_and_registries/container_registry/explorer/pages/list.vue'; +import Tracking from '~/tracking'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +import { $toast } from 'jest/packages_and_registries/shared/mocks'; +import { + graphQLImageListMock, + graphQLImageDeleteMock, + deletedContainerRepository, + graphQLEmptyImageListMock, + graphQLEmptyGroupImageListMock, + pageInfo, + graphQLProjectImageRepositoriesDetailsMock, + dockerCommands, +} from '../mock_data'; +import { GlModal, GlEmptyState } from '../stubs'; + +const localVue = createLocalVue(); + +describe('List Page', () => { + let wrapper; + let apolloProvider; + + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const findCliCommands = () => wrapper.findComponent(CliCommands); + const findProjectEmptyState = () => wrapper.findComponent(ProjectEmptyState); + const findGroupEmptyState = () => wrapper.findComponent(GroupEmptyState); + const findRegistryHeader = () => wrapper.findComponent(RegistryHeader); + + const findDeleteAlert = () => wrapper.findComponent(GlAlert); + const findImageList = () => wrapper.findComponent(ImageList); + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); + const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); + const findDeleteImage = () => wrapper.findComponent(DeleteImage); + const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); + + const waitForApolloRequestRender = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + await nextTick(); + }; + + const mountComponent = ({ + mocks, + resolver = jest.fn().mockResolvedValue(graphQLImageListMock), + detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), + mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock), + config = { isGroupPage: false }, + query = {}, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [getContainerRepositoriesQuery, resolver], + [getContainerRepositoriesDetails, detailsResolver], + [deleteContainerRepositoryMutation, mutationResolver], + ]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + stubs: { + GlModal, + GlEmptyState, + GlSprintf, + RegistryHeader, + TitleArea, + DeleteImage, + }, + mocks: { + $toast, + $route: { + name: 'foo', + query, + }, + ...mocks, + }, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains registry header', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findRegistryHeader().exists()).toBe(true); + expect(findRegistryHeader().props()).toMatchObject({ + imagesCount: 2, + metadataLoading: false, + }); + }); + + describe.each([ + { error: 'connectionError', errorName: 'connection error' }, + { error: 'invalidPathError', errorName: 'invalid path error' }, + ])('handling $errorName', ({ error }) => { + const config = { + containersErrorImage: 'foo', + helpPagePath: 'bar', + isGroupPage: false, + }; + config[error] = true; + + it('should show an empty state', () => { + mountComponent({ config }); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('empty state should have an svg-path', () => { + mountComponent({ config }); + + expect(findEmptyState().props('svgPath')).toBe(config.containersErrorImage); + }); + + it('empty state should have a description', () => { + mountComponent({ config }); + + expect(findEmptyState().props('title')).toContain('connection error'); + }); + + it('should not show the loading or default state', () => { + mountComponent({ config }); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findImageList().exists()).toBe(false); + }); + }); + + describe('isLoading is true', () => { + it('shows the skeleton loader', async () => { + mountComponent(); + + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('imagesList is not visible', () => { + mountComponent(); + + expect(findImageList().exists()).toBe(false); + }); + + it('cli commands is not visible', () => { + mountComponent(); + + expect(findCliCommands().exists()).toBe(false); + }); + + it('title has the metadataLoading props set to true', async () => { + mountComponent(); + + await nextTick(); + + expect(findRegistryHeader().props('metadataLoading')).toBe(true); + }); + }); + + describe('list is empty', () => { + describe('project page', () => { + const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock); + + it('cli commands is not visible', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(false); + }); + + it('project empty state is visible', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findProjectEmptyState().exists()).toBe(true); + }); + }); + + describe('group page', () => { + const resolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock); + + const config = { + isGroupPage: true, + }; + + it('group empty state is visible', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findGroupEmptyState().exists()).toBe(true); + }); + + it('cli commands is not visible', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(false); + }); + }); + }); + + describe('list is not empty', () => { + describe('unfiltered state', () => { + it('quick start is visible', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(true); + }); + + it('list component is visible', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findImageList().exists()).toBe(true); + }); + + describe('additional metadata', () => { + it('is called on component load', async () => { + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ detailsResolver }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(detailsResolver).toHaveBeenCalled(); + }); + + it('does not block the list ui to show', async () => { + const detailsResolver = jest.fn().mockRejectedValue(); + mountComponent({ detailsResolver }); + + await waitForApolloRequestRender(); + + expect(findImageList().exists()).toBe(true); + }); + + it('loading state is passed to list component', async () => { + // this is a promise that never resolves, to trick apollo to think that this request is still loading + const detailsResolver = jest.fn().mockImplementation(() => new Promise(() => {})); + + mountComponent({ detailsResolver }); + await waitForApolloRequestRender(); + + expect(findImageList().props('metadataLoading')).toBe(true); + }); + }); + + describe('delete image', () => { + const selectImageForDeletion = async () => { + await waitForApolloRequestRender(); + + findImageList().vm.$emit('delete', deletedContainerRepository); + }; + + it('should call deleteItem when confirming deletion', async () => { + const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock); + mountComponent({ mutationResolver }); + + await selectImageForDeletion(); + + findDeleteModal().vm.$emit('primary'); + await waitForApolloRequestRender(); + + expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository); + + const updatedImage = findImageList() + .props('images') + .find((i) => i.id === deletedContainerRepository.id); + + expect(updatedImage.status).toBe(deletedContainerRepository.status); + }); + + it('should show a success alert when delete request is successful', async () => { + mountComponent(); + + await selectImageForDeletion(); + + findDeleteImage().vm.$emit('success'); + await nextTick(); + + const alert = findDeleteAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( + DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), + ); + }); + + describe('when delete request fails it shows an alert', () => { + it('user recoverable error', async () => { + mountComponent(); + + await selectImageForDeletion(); + + findDeleteImage().vm.$emit('error'); + await nextTick(); + + const alert = findDeleteAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( + DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), + ); + }); + }); + }); + }); + + describe('search and sorting', () => { + const doSearch = async () => { + await waitForApolloRequestRender(); + findRegistrySearch().vm.$emit('filter:changed', [ + { type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } }, + ]); + + findRegistrySearch().vm.$emit('filter:submit'); + + await nextTick(); + }; + + it('has a search box element', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + const registrySearch = findRegistrySearch(); + expect(registrySearch.exists()).toBe(true); + expect(registrySearch.props()).toMatchObject({ + filter: [], + sorting: { orderBy: 'UPDATED', sort: 'desc' }, + sortableFields: SORT_FIELDS, + tokens: [], + }); + }); + + it('performs sorting', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' }); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' })); + }); + + it('performs a search', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await doSearch(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' })); + }); + + it('when search result is empty displays an empty search message', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + resolver.mockResolvedValue(graphQLEmptyImageListMock); + detailsResolver.mockResolvedValue(graphQLEmptyImageListMock); + + await doSearch(); + + expect(findEmptySearchMessage().exists()).toBe(true); + }); + }); + + describe('pagination', () => { + it('prev-page event triggers a fetchMore request', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + findImageList().vm.$emit('prev-page'); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ before: pageInfo.startCursor }), + ); + expect(detailsResolver).toHaveBeenCalledWith( + expect.objectContaining({ before: pageInfo.startCursor }), + ); + }); + + it('next-page event triggers a fetchMore request', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + findImageList().vm.$emit('next-page'); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pageInfo.endCursor }), + ); + expect(detailsResolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pageInfo.endCursor }), + ); + }); + }); + }); + + describe('modal', () => { + beforeEach(() => { + mountComponent(); + }); + + it('exists', () => { + expect(findDeleteModal().exists()).toBe(true); + }); + + it('contains a description with the path of the item to delete', async () => { + findImageList().vm.$emit('delete', { path: 'foo' }); + await nextTick(); + expect(findDeleteModal().html()).toContain('foo'); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + mountComponent(); + }); + + const testTrackingCall = (action) => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { + label: 'registry_repository_delete', + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + it('send an event when delete button is clicked', () => { + findImageList().vm.$emit('delete', {}); + + testTrackingCall('click_button'); + }); + + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + testTrackingCall('cancel_delete'); + }); + + it('send an event when the deletion starts', () => { + findDeleteImage().vm.$emit('start'); + testTrackingCall('confirm_delete'); + }); + }); + + describe('url query string handling', () => { + const defaultQueryParams = { + search: [1, 2], + sort: 'asc', + orderBy: 'CREATED', + }; + const queryChangePayload = 'foo'; + + it('query:updated event pushes the new query to the router', async () => { + const push = jest.fn(); + mountComponent({ mocks: { $router: { push } } }); + + await nextTick(); + + findRegistrySearch().vm.$emit('query:changed', queryChangePayload); + + expect(push).toHaveBeenCalledWith({ query: queryChangePayload }); + }); + + it('graphql API call has the variables set from the URL', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ query: defaultQueryParams, resolver }); + + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + name: 1, + sort: 'CREATED_ASC', + }), + ); + }); + + it.each` + sort | orderBy | search | payload + ${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }} + ${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }} + ${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }} + ${undefined} | ${undefined} | ${undefined} | ${{}} + ${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }} + ${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }} + ${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }} + ${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }} + `( + 'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload', + async ({ sort, orderBy, search, payload }) => { + const resolver = jest.fn().mockResolvedValue({ sort, orderBy }); + mountComponent({ query: { sort, orderBy, search }, resolver }); + + await nextTick(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload)); + }, + ); + }); + + describe('cleanup is on alert', () => { + it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => { + mountComponent({ + config: { + showCleanupPolicyOnAlert: true, + projectPath: 'foo', + isGroupPage: false, + cleanupPoliciesSettingsPath: 'bar', + }, + }); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(true); + expect(findCleanupAlert().props()).toMatchObject({ + projectPath: 'foo', + cleanupPoliciesSettingsPath: 'bar', + }); + }); + + it('is hidden when showCleanupPolicyOnAlert is false', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js b/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js new file mode 100644 index 00000000000..7d281a53a59 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js @@ -0,0 +1,45 @@ +import { + GlModal as RealGlModal, + GlEmptyState as RealGlEmptyState, + GlSkeletonLoader as RealGlSkeletonLoader, + GlDropdown as RealGlDropdown, +} from '@gitlab/ui'; +import { RouterLinkStub } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import RealDeleteModal from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue'; +import RealListItem from '~/vue_shared/components/registry/list_item.vue'; + +export const GlModal = stubComponent(RealGlModal, { + template: '
    ', + methods: { + show: jest.fn(), + }, +}); + +export const GlEmptyState = stubComponent(RealGlEmptyState, { + template: '
    ', +}); + +export const RouterLink = RouterLinkStub; + +export const DeleteModal = stubComponent(RealDeleteModal, { + methods: { + show: jest.fn(), + }, +}); + +export const GlSkeletonLoader = stubComponent(RealGlSkeletonLoader); + +export const ListItem = { + ...RealListItem, + data() { + return { + detailsSlots: [], + isDetailsShown: true, + }; + }, +}; + +export const GlDropdown = stubComponent(RealGlDropdown, { + template: '
    ', +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index 1f0252965b0..625f00a8666 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -1,32 +1,40 @@ -import { GlFormInputGroup, GlFormGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; +import { + GlFormInputGroup, + GlFormGroup, + GlSkeletonLoader, + GlSprintf, + GlEmptyState, +} from '@gitlab/ui'; import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stripTypenames } from 'helpers/graphql_helpers'; import waitForPromises from 'helpers/wait_for_promises'; +import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; import DependencyProxyApp from '~/packages_and_registries/dependency_proxy/app.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; -import { proxyDetailsQuery, proxyData } from './mock_data'; +import { proxyDetailsQuery, proxyData, pagination, proxyManifests } from './mock_data'; const localVue = createLocalVue(); describe('DependencyProxyApp', () => { let wrapper; let apolloProvider; + let resolver; const provideDefaults = { groupPath: 'gitlab-org', dependencyProxyAvailable: true, + noManifestsIllustration: 'noManifestsIllustration', }; - function createComponent({ - provide = provideDefaults, - resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()), - } = {}) { + function createComponent({ provide = provideDefaults } = {}) { localVue.use(VueApollo); const requestHandlers = [[getDependencyProxyDetailsQuery, resolver]]; @@ -53,6 +61,12 @@ describe('DependencyProxyApp', () => { const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findMainArea = () => wrapper.findByTestId('main-area'); const findProxyCountText = () => wrapper.findByTestId('proxy-count'); + const findManifestList = () => wrapper.findComponent(ManifestsList); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); + }); afterEach(() => { wrapper.destroy(); @@ -78,8 +92,8 @@ describe('DependencyProxyApp', () => { }); it('does not call the graphql endpoint', async () => { - const resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); - createComponent({ ...createComponentArguments, resolver }); + resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); + createComponent({ ...createComponentArguments }); await waitForPromises(); @@ -145,14 +159,73 @@ describe('DependencyProxyApp', () => { it('from group has a description with proxy count', () => { expect(findProxyCountText().text()).toBe('Contains 2 blobs of images (1024 Bytes)'); }); + + describe('manifest lists', () => { + describe('when there are no manifests', () => { + beforeEach(() => { + resolver = jest.fn().mockResolvedValue( + proxyDetailsQuery({ + extend: { dependencyProxyManifests: { nodes: [], pageInfo: pagination() } }, + }), + ); + createComponent(); + return waitForPromises(); + }); + + it('shows the empty state message', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: provideDefaults.noManifestsIllustration, + title: DependencyProxyApp.i18n.noManifestTitle, + }); + }); + + it('hides the list', () => { + expect(findManifestList().exists()).toBe(false); + }); + }); + + describe('when there are manifests', () => { + it('hides the empty state message', () => { + expect(findEmptyState().exists()).toBe(false); + }); + + it('shows list', () => { + expect(findManifestList().props()).toMatchObject({ + manifests: proxyManifests(), + pagination: stripTypenames(pagination()), + }); + }); + + it('prev-page event on list fetches the previous page', () => { + findManifestList().vm.$emit('prev-page'); + + expect(resolver).toHaveBeenCalledWith({ + before: pagination().startCursor, + first: null, + fullPath: provideDefaults.groupPath, + last: GRAPHQL_PAGE_SIZE, + }); + }); + + it('next-page event on list fetches the next page', () => { + findManifestList().vm.$emit('next-page'); + + expect(resolver).toHaveBeenCalledWith({ + after: pagination().endCursor, + first: GRAPHQL_PAGE_SIZE, + fullPath: provideDefaults.groupPath, + }); + }); + }); + }); }); + describe('when the dependency proxy is disabled', () => { beforeEach(() => { - createComponent({ - resolver: jest - .fn() - .mockResolvedValue(proxyDetailsQuery({ extendSettings: { enabled: false } })), - }); + resolver = jest + .fn() + .mockResolvedValue(proxyDetailsQuery({ extendSettings: { enabled: false } })); + createComponent(); return waitForPromises(); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js new file mode 100644 index 00000000000..9e4c747a1bd --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js @@ -0,0 +1,84 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { stripTypenames } from 'helpers/graphql_helpers'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; + +import Component from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; +import { + proxyManifests, + pagination, +} from 'jest/packages_and_registries/dependency_proxy/mock_data'; + +describe('Manifests List', () => { + let wrapper; + + const defaultProps = { + manifests: proxyManifests(), + pagination: stripTypenames(pagination()), + }; + + const createComponent = (propsData = defaultProps) => { + wrapper = shallowMountExtended(Component, { + propsData, + }); + }; + + const findRows = () => wrapper.findAllComponents(ManifestRow); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has the correct title', () => { + createComponent(); + + expect(wrapper.text()).toContain(Component.i18n.listTitle); + }); + + it('shows a row for every manifest', () => { + createComponent(); + + expect(findRows().length).toBe(defaultProps.manifests.length); + }); + + it('binds a manifest to each row', () => { + createComponent(); + + expect(findRows().at(0).props()).toMatchObject({ + manifest: defaultProps.manifests[0], + }); + }); + + describe('pagination', () => { + it('is hidden when there is no next or prev pages', () => { + createComponent({ ...defaultProps, pagination: {} }); + + expect(findPagination().exists()).toBe(false); + }); + + it('has the correct props', () => { + createComponent(); + + expect(findPagination().props()).toMatchObject({ + ...defaultProps.pagination, + }); + }); + + it('emits the next-page event', () => { + createComponent(); + + findPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toEqual([[]]); + }); + + it('emits the prev-page event', () => { + createComponent(); + + findPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js new file mode 100644 index 00000000000..b7cbd875497 --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js @@ -0,0 +1,59 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import Component from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; +import { proxyManifests } from 'jest/packages_and_registries/dependency_proxy/mock_data'; + +describe('Manifest Row', () => { + let wrapper; + + const defaultProps = { + manifest: proxyManifests()[0], + }; + + const createComponent = (propsData = defaultProps) => { + wrapper = shallowMountExtended(Component, { + propsData, + stubs: { + GlSprintf, + TimeagoTooltip, + ListItem, + }, + }); + }; + + const findListItem = () => wrapper.findComponent(ListItem); + const findCachedMessages = () => wrapper.findByTestId('cached-message'); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a list item', () => { + expect(findListItem().exists()).toBe(true); + }); + + it('displays the name', () => { + expect(wrapper.text()).toContain('alpine'); + }); + + it('displays the version', () => { + expect(wrapper.text()).toContain('latest'); + }); + + it('displays the cached time', () => { + expect(findCachedMessages().text()).toContain('Cached'); + }); + + it('has a time ago tooltip component', () => { + expect(findTimeAgoTooltip().props()).toMatchObject({ + time: defaultProps.manifest.createdAt, + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js index 23d42e109f9..8bad22b5287 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js @@ -7,7 +7,21 @@ export const proxyData = () => ({ export const proxySettings = (extend = {}) => ({ enabled: true, ...extend }); -export const proxyDetailsQuery = ({ extendSettings = {} } = {}) => ({ +export const proxyManifests = () => [ + { createdAt: '2021-09-22T09:45:28Z', imageName: 'alpine:latest' }, + { createdAt: '2021-09-21T09:45:28Z', imageName: 'alpine:stable' }, +]; + +export const pagination = (extend) => ({ + endCursor: 'eyJpZCI6IjIwNSIsIm5hbWUiOiJteS9jb21wYW55L2FwcC9teS1hcHAifQ', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjI0NyIsIm5hbWUiOiJ2ZXJzaW9uX3Rlc3QxIn0', + __typename: 'PageInfo', + ...extend, +}); + +export const proxyDetailsQuery = ({ extendSettings = {}, extend } = {}) => ({ data: { group: { ...proxyData(), @@ -16,6 +30,11 @@ export const proxyDetailsQuery = ({ extendSettings = {} } = {}) => ({ ...proxySettings(extendSettings), __typename: 'DependencyProxySetting', }, + dependencyProxyManifests: { + nodes: proxyManifests(), + pageInfo: pagination(), + }, + ...extend, }, }, }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap index 451cf743e35..519014bb9cf 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap @@ -19,15 +19,15 @@ exports[`PackageTitle renders with tags 1`] = `
    -

    @gitlab-org/package-15 -

    +
    -

    @gitlab-org/package-15 -

    +
    { function createComponent({ resolver = jest.fn().mockResolvedValue(packageDetailsQuery()), - mutationResolver = jest.fn().mockResolvedValue(packageDestroyMutation()), fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()), } = {}) { localVue.use(VueApollo); const requestHandlers = [ [getPackageDetails, resolver], - [destroyPackageMutation, mutationResolver], [destroyPackageFileMutation, fileDeleteMutationResolver], ]; apolloProvider = createMockApollo(requestHandlers); @@ -82,6 +77,7 @@ describe('PackagesApp', () => { provide, stubs: { PackageTitle, + DeletePackage, GlModal: { template: '
    ', methods: { @@ -108,6 +104,7 @@ describe('PackagesApp', () => { const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge); const findNoDependenciesMessage = () => wrapper.findByTestId('no-dependencies-message'); const findDependencyRows = () => wrapper.findAllComponents(DependencyRow); + const findDeletePackage = () => wrapper.findComponent(DeletePackage); afterEach(() => { wrapper.destroy(); @@ -187,14 +184,6 @@ describe('PackagesApp', () => { }); }; - const performDeletePackage = async () => { - await findDeleteButton().trigger('click'); - - findDeleteModal().vm.$emit('primary'); - - await waitForPromises(); - }; - afterEach(() => { Object.defineProperty(document, 'referrer', { value: originalReferrer, @@ -220,7 +209,7 @@ describe('PackagesApp', () => { await waitForPromises(); - await performDeletePackage(); + findDeletePackage().vm.$emit('end'); expect(window.location.replace).toHaveBeenCalledWith( 'projectListUrl?showSuccessDeleteAlert=true', @@ -234,45 +223,13 @@ describe('PackagesApp', () => { await waitForPromises(); - await performDeletePackage(); + findDeletePackage().vm.$emit('end'); expect(window.location.replace).toHaveBeenCalledWith( 'groupListUrl?showSuccessDeleteAlert=true', ); }); }); - - describe('request failure', () => { - it('on global failure it displays an alert', async () => { - createComponent({ mutationResolver: jest.fn().mockRejectedValue() }); - - await waitForPromises(); - - await performDeletePackage(); - - expect(createFlash).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_ERROR_MESSAGE, - }), - ); - }); - - it('on payload with error it displays an alert', async () => { - createComponent({ - mutationResolver: jest.fn().mockResolvedValue(packageDestroyMutationError()), - }); - - await waitForPromises(); - - await performDeletePackage(); - - expect(createFlash).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_ERROR_MESSAGE, - }), - ); - }); - }); }); describe('package files', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js index b24946c8638..8bb05b00e65 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js @@ -33,12 +33,12 @@ describe('InstallationCommands', () => { }); } - const npmInstallation = () => wrapper.find(NpmInstallation); - const mavenInstallation = () => wrapper.find(MavenInstallation); - const conanInstallation = () => wrapper.find(ConanInstallation); - const nugetInstallation = () => wrapper.find(NugetInstallation); - const pypiInstallation = () => wrapper.find(PypiInstallation); - const composerInstallation = () => wrapper.find(ComposerInstallation); + const npmInstallation = () => wrapper.findComponent(NpmInstallation); + const mavenInstallation = () => wrapper.findComponent(MavenInstallation); + const conanInstallation = () => wrapper.findComponent(ConanInstallation); + const nugetInstallation = () => wrapper.findComponent(NugetInstallation); + const pypiInstallation = () => wrapper.findComponent(PypiInstallation); + const composerInstallation = () => wrapper.findComponent(ComposerInstallation); afterEach(() => { wrapper.destroy(); @@ -57,7 +57,7 @@ describe('InstallationCommands', () => { it(`${packageEntity.packageType} instructions exist`, () => { createComponent({ packageEntity }); - expect(selector()).toExist(); + expect(selector().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js b/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js new file mode 100644 index 00000000000..5de30829fa5 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js @@ -0,0 +1,160 @@ +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import createFlash from '~/flash'; +import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; + +import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; +import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; +import { + packageDestroyMutation, + packageDestroyMutationError, + packagesListQuery, +} from '../../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('DeletePackage', () => { + let wrapper; + let apolloProvider; + let resolver; + let mutationResolver; + + const eventPayload = { id: '1' }; + + function createComponent(propsData = {}) { + localVue.use(VueApollo); + + const requestHandlers = [ + [getPackagesQuery, resolver], + [destroyPackageMutation, mutationResolver], + ]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(DeletePackage, { + propsData, + localVue, + apolloProvider, + scopedSlots: { + default(props) { + return this.$createElement('button', { + attrs: { + 'data-testid': 'trigger-button', + }, + on: { + click: props.deletePackage, + }, + }); + }, + }, + }); + } + + const findButton = () => wrapper.findByTestId('trigger-button'); + + const clickOnButtonAndWait = (payload) => { + findButton().trigger('click', payload); + return waitForPromises(); + }; + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(packagesListQuery()); + mutationResolver = jest.fn().mockResolvedValue(packageDestroyMutation()); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('binds deletePackage method to the default slot', () => { + createComponent(); + + findButton().trigger('click'); + + expect(wrapper.emitted('start')).toEqual([[]]); + }); + + it('calls apollo mutation', async () => { + createComponent(); + + await clickOnButtonAndWait(eventPayload); + + expect(mutationResolver).toHaveBeenCalledWith(eventPayload); + }); + + it('passes refetchQueries to apollo mutate', async () => { + const variables = { isGroupPage: true }; + createComponent({ + refetchQueries: [{ query: getPackagesQuery, variables }], + }); + + await clickOnButtonAndWait(eventPayload); + + expect(mutationResolver).toHaveBeenCalledWith(eventPayload); + expect(resolver).toHaveBeenCalledWith(variables); + }); + + describe('on mutation success', () => { + it('emits end event', async () => { + createComponent(); + + await clickOnButtonAndWait(eventPayload); + + expect(wrapper.emitted('end')).toEqual([[]]); + }); + + it('does not call createFlash', async () => { + createComponent(); + + await clickOnButtonAndWait(eventPayload); + + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('calls createFlash with the success message when showSuccessAlert is true', async () => { + createComponent({ showSuccessAlert: true }); + + await clickOnButtonAndWait(eventPayload); + + expect(createFlash).toHaveBeenCalledWith({ + message: DeletePackage.i18n.successMessage, + type: 'success', + }); + }); + }); + + describe.each` + errorType | mutationResolverResponse + ${'connectionError'} | ${jest.fn().mockRejectedValue()} + ${'localError'} | ${jest.fn().mockResolvedValue(packageDestroyMutationError())} + `('on mutation $errorType', ({ mutationResolverResponse }) => { + beforeEach(() => { + mutationResolver = mutationResolverResponse; + }); + + it('emits end event', async () => { + createComponent(); + + await clickOnButtonAndWait(eventPayload); + + expect(wrapper.emitted('end')).toEqual([[]]); + }); + + it('calls createFlash with the error message', async () => { + createComponent({ showSuccessAlert: true }); + + await clickOnButtonAndWait(eventPayload); + + expect(createFlash).toHaveBeenCalledWith({ + message: DeletePackage.i18n.errorMessage, + type: 'warning', + captureError: true, + error: expect.any(Error), + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap index 1b556be5873..5af75868084 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap @@ -8,5 +8,62 @@ exports[`PackagesListApp renders 1`] = ` /> + +
    +
    +
    +
    + +
    +
    + +
    +
    +

    + There are no packages yet +

    + +

    + Learn how to + + publish and share your packages + + with GitLab. +

    + +
    + + + +
    +
    +
    +
    +
    `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js index 3958cdf21bb..ad848f367e0 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js @@ -2,22 +2,25 @@ import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PackageListApp from '~/packages_and_registries/package_registry/components/list/app.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; +import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; +import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, - LIST_QUERY_DEBOUNCE_TIME, + GRAPHQL_PAGE_SIZE, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; -import { packagesListQuery } from '../../mock_data'; +import { packagesListQuery, packageData, pagination } from '../../mock_data'; jest.mock('~/lib/utils/common_utils'); jest.mock('~/flash'); @@ -39,11 +42,20 @@ describe('PackagesListApp', () => { const PackageList = { name: 'package-list', template: '
    ', + props: OriginalPackageList.props, }; const GlLoadingIcon = { name: 'gl-loading-icon', template: '
    loading
    ' }; + const searchPayload = { + sort: 'VERSION_DESC', + filters: { packageName: 'foo', packageType: 'CONAN' }, + }; + const findPackageTitle = () => wrapper.findComponent(PackageTitle); const findSearch = () => wrapper.findComponent(PackageSearch); + const findListComponent = () => wrapper.findComponent(PackageList); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findDeletePackage = () => wrapper.findComponent(DeletePackage); const mountComponent = ({ resolver = jest.fn().mockResolvedValue(packagesListQuery()), @@ -61,9 +73,10 @@ describe('PackagesListApp', () => { stubs: { GlEmptyState, GlLoadingIcon, - PackageList, GlSprintf, GlLink, + PackageList, + DeletePackage, }, }); }; @@ -72,15 +85,24 @@ describe('PackagesListApp', () => { wrapper.destroy(); }); - const waitForDebouncedApollo = () => { - jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME); + const waitForFirstRequest = () => { + // emit a search update so the query is executed + findSearch().vm.$emit('update', { sort: 'NAME_DESC', filters: [] }); return waitForPromises(); }; + it('does not execute the query without sort being set', () => { + const resolver = jest.fn().mockResolvedValue(packagesListQuery()); + + mountComponent({ resolver }); + + expect(resolver).not.toHaveBeenCalled(); + }); + it('renders', async () => { mountComponent(); - await waitForDebouncedApollo(); + await waitForFirstRequest(); expect(wrapper.element).toMatchSnapshot(); }); @@ -88,7 +110,7 @@ describe('PackagesListApp', () => { it('has a package title', async () => { mountComponent(); - await waitForDebouncedApollo(); + await waitForFirstRequest(); expect(findPackageTitle().exists()).toBe(true); expect(findPackageTitle().props('count')).toBe(2); @@ -105,25 +127,54 @@ describe('PackagesListApp', () => { const resolver = jest.fn().mockResolvedValue(packagesListQuery()); mountComponent({ resolver }); - const payload = { - sort: 'VERSION_DESC', - filters: { packageName: 'foo', packageType: 'CONAN' }, - }; - - findSearch().vm.$emit('update', payload); + findSearch().vm.$emit('update', searchPayload); - await waitForDebouncedApollo(); - jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME); + await waitForPromises(); expect(resolver).toHaveBeenCalledWith( expect.objectContaining({ - groupSort: payload.sort, - ...payload.filters, + groupSort: searchPayload.sort, + ...searchPayload.filters, }), ); }); }); + describe('list component', () => { + let resolver; + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(packagesListQuery()); + mountComponent({ resolver }); + + return waitForFirstRequest(); + }); + + it('exists and has the right props', () => { + expect(findListComponent().props()).toMatchObject({ + list: expect.arrayContaining([expect.objectContaining({ id: packageData().id })]), + isLoading: false, + pageInfo: expect.objectContaining({ endCursor: pagination().endCursor }), + }); + }); + + it('when list emits next-page fetches the next set of records', () => { + findListComponent().vm.$emit('next-page'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pagination().endCursor, first: GRAPHQL_PAGE_SIZE }), + ); + }); + + it('when list emits prev-page fetches the prev set of records', () => { + findListComponent().vm.$emit('prev-page'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ before: pagination().startCursor, last: GRAPHQL_PAGE_SIZE }), + ); + }); + }); + describe.each` type | sortType ${PROJECT_RESOURCE_TYPE} | ${'sort'} @@ -136,9 +187,9 @@ describe('PackagesListApp', () => { beforeEach(() => { provide = { ...defaultProvide, isGroupPage }; - resolver = jest.fn().mockResolvedValue(packagesListQuery(type)); + resolver = jest.fn().mockResolvedValue(packagesListQuery({ type })); mountComponent({ provide, resolver }); - return waitForDebouncedApollo(); + return waitForFirstRequest(); }); it('succeeds', () => { @@ -147,8 +198,85 @@ describe('PackagesListApp', () => { it('calls the resolver with the right parameters', () => { expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ isGroupPage, [sortType]: '' }), + expect.objectContaining({ isGroupPage, [sortType]: 'NAME_DESC' }), ); }); }); + + describe('empty state', () => { + beforeEach(() => { + const resolver = jest.fn().mockResolvedValue(packagesListQuery({ extend: { nodes: [] } })); + mountComponent({ resolver }); + + return waitForFirstRequest(); + }); + it('generate the correct empty list link', () => { + const link = findListComponent().findComponent(GlLink); + + expect(link.attributes('href')).toBe(defaultProvide.emptyListHelpUrl); + expect(link.text()).toBe('publish and share your packages'); + }); + + it('includes the right content on the default tab', () => { + expect(findEmptyState().text()).toContain(PackageListApp.i18n.emptyPageTitle); + }); + }); + + describe('filter without results', () => { + beforeEach(async () => { + mountComponent(); + + await waitForFirstRequest(); + + findSearch().vm.$emit('update', searchPayload); + + return nextTick(); + }); + + it('should show specific empty message', () => { + expect(findEmptyState().text()).toContain(PackageListApp.i18n.noResultsTitle); + expect(findEmptyState().text()).toContain(PackageListApp.i18n.widenFilters); + }); + }); + + describe('delete package', () => { + it('exists and has the correct props', async () => { + mountComponent(); + + await waitForFirstRequest(); + + expect(findDeletePackage().props()).toMatchObject({ + refetchQueries: [{ query: getPackagesQuery, variables: {} }], + showSuccessAlert: true, + }); + }); + + it('deletePackage is bound to package-list package:delete event', async () => { + mountComponent(); + + await waitForFirstRequest(); + + findListComponent().vm.$emit('package:delete', { id: 1 }); + + expect(findDeletePackage().emitted('start')).toEqual([[]]); + }); + + it('start and end event set loading correctly', async () => { + mountComponent(); + + await waitForFirstRequest(); + + findDeletePackage().vm.$emit('start'); + + await nextTick(); + + expect(findListComponent().props('isLoading')).toBe(true); + + findDeletePackage().vm.$emit('end'); + + await nextTick(); + + expect(findListComponent().props('isLoading')).toBe(false); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js index b624e66482d..de4e9c8ae5b 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -1,93 +1,86 @@ -import { GlTable, GlPagination, GlModal } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; -import { last } from 'lodash'; -import Vuex from 'vuex'; -import stubChildren from 'helpers/stub_children'; -import { packageList } from 'jest/packages/mock_data'; +import { GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; -import { TrackingActions } from '~/packages/shared/constants'; -import * as SharedUtils from '~/packages/shared/utils'; +import { + DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, +} from '~/packages_and_registries/package_registry/constants'; import PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; import Tracking from '~/tracking'; - -const localVue = createLocalVue(); -localVue.use(Vuex); +import { packageData } from '../../mock_data'; describe('packages_list', () => { let wrapper; - let store; + + const firstPackage = packageData(); + const secondPackage = { + ...packageData(), + id: 'gid://gitlab/Packages::Package/112', + name: 'second-package', + }; + + const defaultProps = { + list: [firstPackage, secondPackage], + isLoading: false, + pageInfo: {}, + }; const EmptySlotStub = { name: 'empty-slot-stub', template: '
    bar
    ' }; + const GlModalStub = { + name: GlModal.name, + template: '
    ', + methods: { show: jest.fn() }, + }; - const findPackagesListLoader = () => wrapper.find(PackagesListLoader); - const findPackageListPagination = () => wrapper.find(GlPagination); - const findPackageListDeleteModal = () => wrapper.find(GlModal); - const findEmptySlot = () => wrapper.find(EmptySlotStub); - const findPackagesListRow = () => wrapper.find(PackagesListRow); - - const createStore = (isGroupPage, packages, isLoading) => { - const state = { - isLoading, - packages, - pagination: { - perPage: 1, - total: 1, - page: 1, - }, - config: { - isGroupPage, + const findPackagesListLoader = () => wrapper.findComponent(PackagesListLoader); + const findPackageListPagination = () => wrapper.findComponent(GlKeysetPagination); + const findPackageListDeleteModal = () => wrapper.findComponent(GlModalStub); + const findEmptySlot = () => wrapper.findComponent(EmptySlotStub); + const findPackagesListRow = () => wrapper.findComponent(PackagesListRow); + + const mountComponent = (props) => { + wrapper = shallowMountExtended(PackagesList, { + propsData: { + ...defaultProps, + ...props, }, - sorting: { - orderBy: 'version', - sort: 'desc', + stubs: { + GlModal: GlModalStub, + GlSprintf, }, - }; - store = new Vuex.Store({ - state, - getters: { - getList: () => packages, + slots: { + 'empty-state': EmptySlotStub, }, }); - store.dispatch = jest.fn(); }; - const mountComponent = ({ - isGroupPage = false, - packages = packageList, - isLoading = false, - ...options - } = {}) => { - createStore(isGroupPage, packages, isLoading); - - wrapper = mount(PackagesList, { - localVue, - store, - stubs: { - ...stubChildren(PackagesList), - GlTable, - GlModal, - }, - ...options, - }); - }; + beforeEach(() => { + GlModalStub.methods.show.mockReset(); + }); afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('when is loading', () => { beforeEach(() => { - mountComponent({ - packages: [], - isLoading: true, - }); + mountComponent({ isLoading: true }); }); - it('shows skeleton loader when loading', () => { + it('shows skeleton loader', () => { expect(findPackagesListLoader().exists()).toBe(true); }); + + it('does not show the rows', () => { + expect(findPackagesListRow().exists()).toBe(false); + }); + + it('does not show the pagination', () => { + expect(findPackageListPagination().exists()).toBe(false); + }); }); describe('when is not loading', () => { @@ -95,74 +88,61 @@ describe('packages_list', () => { mountComponent(); }); - it('does not show skeleton loader when not loading', () => { + it('does not show skeleton loader', () => { expect(findPackagesListLoader().exists()).toBe(false); }); - }); - describe('layout', () => { - beforeEach(() => { - mountComponent(); + it('shows the rows', () => { + expect(findPackagesListRow().exists()).toBe(true); }); + }); + describe('layout', () => { it('contains a pagination component', () => { - const sorting = findPackageListPagination(); - expect(sorting.exists()).toBe(true); + mountComponent({ pageInfo: { hasPreviousPage: true } }); + + expect(findPackageListPagination().exists()).toBe(true); }); it('contains a modal component', () => { - const sorting = findPackageListDeleteModal(); - expect(sorting.exists()).toBe(true); + mountComponent(); + + expect(findPackageListDeleteModal().exists()).toBe(true); }); }); describe('when the user can destroy the package', () => { beforeEach(() => { mountComponent(); + findPackagesListRow().vm.$emit('packageToDelete', firstPackage); + return nextTick(); }); - it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => { - const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show'); - const item = last(wrapper.vm.list); + it('deleting a package opens the modal', () => { + expect(findPackageListDeleteModal().text()).toContain(firstPackage.name); + }); - findPackagesListRow().vm.$emit('packageToDelete', item); + it('confirming on the modal emits package:delete', async () => { + findPackageListDeleteModal().vm.$emit('ok'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.itemToBeDeleted).toEqual(item); - expect(mockModalShow).toHaveBeenCalled(); - }); - }); + await nextTick(); - it('deleteItemConfirmation resets itemToBeDeleted', () => { - wrapper.setData({ itemToBeDeleted: 1 }); - wrapper.vm.deleteItemConfirmation(); - expect(wrapper.vm.itemToBeDeleted).toEqual(null); + expect(wrapper.emitted('package:delete')[0]).toEqual([firstPackage]); }); - it('deleteItemConfirmation emit package:delete', () => { - const itemToBeDeleted = { id: 2 }; - wrapper.setData({ itemToBeDeleted }); - wrapper.vm.deleteItemConfirmation(); - return wrapper.vm.$nextTick(() => { - expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]); - }); - }); + it('closing the modal resets itemToBeDeleted', async () => { + // triggering the v-model + findPackageListDeleteModal().vm.$emit('input', false); - it('deleteItemCanceled resets itemToBeDeleted', () => { - wrapper.setData({ itemToBeDeleted: 1 }); - wrapper.vm.deleteItemCanceled(); - expect(wrapper.vm.itemToBeDeleted).toEqual(null); + await nextTick(); + + expect(findPackageListDeleteModal().text()).not.toContain(firstPackage.name); }); }); describe('when the list is empty', () => { beforeEach(() => { - mountComponent({ - packages: [], - slots: { - 'empty-state': EmptySlotStub, - }, - }); + mountComponent({ list: [] }); }); it('show the empty slot', () => { @@ -171,45 +151,59 @@ describe('packages_list', () => { }); }); - describe('pagination component', () => { - let pagination; - let modelEvent; - + describe('pagination ', () => { beforeEach(() => { - mountComponent(); - pagination = findPackageListPagination(); - // retrieve the event used by v-model, a more sturdy approach than hardcoding it - modelEvent = pagination.vm.$options.model.event; + mountComponent({ pageInfo: { hasPreviousPage: true } }); }); - it('emits page:changed events when the page changes', () => { - pagination.vm.$emit(modelEvent, 2); - expect(wrapper.emitted('page:changed')).toEqual([[2]]); + it('emits prev-page events when the prev event is fired', () => { + findPackageListPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toEqual([[]]); + }); + + it('emits next-page events when the next event is fired', () => { + findPackageListPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toEqual([[]]); }); }); describe('tracking', () => { let eventSpy; - let utilSpy; - const category = 'foo'; + const category = 'UI::NpmPackages'; beforeEach(() => { - mountComponent(); eventSpy = jest.spyOn(Tracking, 'event'); - utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); - wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); + mountComponent(); + findPackagesListRow().vm.$emit('packageToDelete', firstPackage); + return nextTick(); }); - it('tracking category calls packageTypeToTrackCategory', () => { - expect(wrapper.vm.tracking.category).toBe(category); - expect(utilSpy).toHaveBeenCalledWith('conan'); + it('requesting the delete tracks the right action', () => { + expect(eventSpy).toHaveBeenCalledWith( + category, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + expect.any(Object), + ); + }); + + it('confirming delete tracks the right action', () => { + findPackageListDeleteModal().vm.$emit('ok'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + DELETE_PACKAGE_TRACKING_ACTION, + expect.any(Object), + ); }); - it('deleteItemConfirmation calls event', () => { - wrapper.vm.deleteItemConfirmation(); + it('canceling delete tracks the right action', () => { + findPackageListDeleteModal().vm.$emit('cancel'); + expect(eventSpy).toHaveBeenCalledWith( category, - TrackingActions.DELETE_PACKAGE, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, expect.any(Object), ); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index e65b2a6f320..bed7a07ff36 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { sortableFields } from '~/packages/list/utils'; +import { sortableFields } from '~/packages_and_registries/package_registry/utils'; import component from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageTypeToken from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js index 3fa96ce1d29..e992ba12faa 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -37,8 +36,8 @@ describe('PackageTitle', () => { mountComponent(); expect(findTitleArea().props()).toMatchObject({ - title: LIST_TITLE_TEXT, - infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }], + title: PackageTitle.i18n.LIST_TITLE_TEXT, + infoMessages: [{ text: PackageTitle.i18n.LIST_INTRO_TEXT, link: 'foo' }], }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js index b0cbe34f0b9..26b2f3b359f 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js @@ -1,7 +1,7 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import component from '~/packages/list/components/tokens/package_type_token.vue'; -import { PACKAGE_TYPES } from '~/packages/list/constants'; +import component from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue'; +import { PACKAGE_TYPES } from '~/packages_and_registries/package_registry/constants'; describe('packages_filter', () => { let wrapper; @@ -41,8 +41,8 @@ describe('packages_filter', () => { (packageType, index) => { mountComponent(); const item = findFilteredSearchSuggestions().at(index); - expect(item.text()).toBe(packageType.title); - expect(item.props('value')).toBe(packageType.type); + expect(item.text()).toBe(packageType); + expect(item.props('value')).toBe(packageType); }, ); }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 70fc096fa44..bacc748db81 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -1,3 +1,5 @@ +import capitalize from 'lodash/capitalize'; + export const packageTags = () => [ { id: 'gid://gitlab/Packages::Tag/87', name: 'bananas_9', __typename: 'PackageTag' }, { id: 'gid://gitlab/Packages::Tag/86', name: 'bananas_8', __typename: 'PackageTag' }, @@ -156,6 +158,15 @@ export const nugetMetadata = () => ({ projectUrl: 'projectUrl', }); +export const pagination = (extend) => ({ + endCursor: 'eyJpZCI6IjIwNSIsIm5hbWUiOiJteS9jb21wYW55L2FwcC9teS1hcHAifQ', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjI0NyIsIm5hbWUiOiJ2ZXJzaW9uX3Rlc3QxIn0', + __typename: 'PageInfo', + ...extend, +}); + export const packageDetailsQuery = (extendPackage) => ({ data: { package: { @@ -256,7 +267,7 @@ export const packageDestroyFileMutationError = () => ({ ], }); -export const packagesListQuery = (type = 'group') => ({ +export const packagesListQuery = ({ type = 'group', extend = {}, extendPagination = {} } = {}) => ({ data: { [type]: { packages: { @@ -277,9 +288,11 @@ export const packagesListQuery = (type = 'group') => ({ pipelines: { nodes: [] }, }, ], + pageInfo: pagination(extendPagination), __typename: 'PackageConnection', }, - __typename: 'Group', + ...extend, + __typename: capitalize(type), }, }, }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js index c56244a9138..5c9ade7f785 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlFormGroup, GlFormSelect } from 'jest/registry/shared/stubs'; +import { GlFormGroup, GlFormSelect } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/expiration_dropdown.vue'; describe('ExpirationDropdown', () => { diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js index dd876d1d295..6b681924fcf 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js @@ -1,6 +1,6 @@ import { GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { GlFormGroup } from 'jest/registry/shared/stubs'; +import { GlFormGroup } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/expiration_input.vue'; import { NAME_REGEX_LENGTH } from '~/packages_and_registries/settings/project/constants'; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js index 854830391c5..94f7783afe7 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js @@ -1,6 +1,6 @@ import { GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { GlFormGroup } from 'jest/registry/shared/stubs'; +import { GlFormGroup } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/expiration_run_text.vue'; import { NEXT_CLEANUP_LABEL, diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js index 3a3eb089b43..45039614e49 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js @@ -1,6 +1,6 @@ import { GlToggle, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { GlFormGroup } from 'jest/registry/shared/stubs'; +import { GlFormGroup } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/expiration_toggle.vue'; import { ENABLED_TOGGLE_DESCRIPTION, diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js index 3a71af94d5a..bc104a25ef9 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js @@ -2,7 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { GlCard, GlLoadingIcon } from 'jest/registry/shared/stubs'; +import { GlCard, GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/settings_form.vue'; import { UPDATE_SETTINGS_ERROR_MESSAGE, diff --git a/spec/frontend/packages_and_registries/shared/mocks.js b/spec/frontend/packages_and_registries/shared/mocks.js new file mode 100644 index 00000000000..fdef38b6f10 --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/mocks.js @@ -0,0 +1,3 @@ +export const $toast = { + show: jest.fn(), +}; diff --git a/spec/frontend/packages_and_registries/shared/stubs.js b/spec/frontend/packages_and_registries/shared/stubs.js new file mode 100644 index 00000000000..ad41eb42df4 --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/stubs.js @@ -0,0 +1,31 @@ +export const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '' }; +export const GlCard = { + name: 'gl-card-stub', + template: ` +
    + + + +
    +`, +}; + +export const GlFormGroup = { + name: 'gl-form-group-stub', + props: ['state'], + template: ` +
    + + + +
    `, +}; + +export const GlFormSelect = { + name: 'gl-form-select-stub', + props: ['disabled', 'value'], + template: ` +
    + +
    `, +}; diff --git a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js index c579aa2f2da..1fcc00489e3 100644 --- a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js +++ b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js @@ -38,7 +38,7 @@ describe('Dropdown select component', () => { it('creates a hidden input if fieldName is provided', () => { mountDropdown({ fieldName: 'namespace-input' }); - expect(findNamespaceInput()).toExist(); + expect(findNamespaceInput().exists()).toBe(true); expect(findNamespaceInput().attributes('name')).toBe('namespace-input'); }); @@ -57,9 +57,9 @@ describe('Dropdown select component', () => { // wait for dropdown options to populate await wrapper.vm.$nextTick(); - expect(findDropdownOption('user: Administrator')).toExist(); - expect(findDropdownOption('group: GitLab Org')).toExist(); - expect(findDropdownOption('group: Foobar')).not.toExist(); + expect(findDropdownOption('user: Administrator').exists()).toBe(true); + expect(findDropdownOption('group: GitLab Org').exists()).toBe(true); + expect(findDropdownOption('group: Foobar').exists()).toBe(false); findDropdownOption('user: Administrator').trigger('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index de8b29d54fc..5bba98bdf96 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -94,13 +94,13 @@ describe('Todos', () => { }); it('updates pending text', () => { - expect(document.querySelector('.todos-pending .badge').innerHTML).toEqual( + expect(document.querySelector('.js-todos-pending .badge').innerHTML).toEqual( addDelimiter(TEST_COUNT_BIG), ); }); it('updates done text', () => { - expect(document.querySelector('.todos-done .badge').innerHTML).toEqual( + expect(document.querySelector('.js-todos-done .badge').innerHTML).toEqual( addDelimiter(TEST_DONE_COUNT_BIG), ); }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap index 3aa0e99a858..3e371a8765f 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap @@ -135,6 +135,7 @@ exports[`Learn GitLab renders correctly 1`] = ` { let wrapper; + let inviteMembersOpen = false; const createWrapper = () => { - wrapper = mount(LearnGitlab, { propsData: { actions: testActions, sections: testSections } }); + wrapper = mount(LearnGitlab, { + propsData: { actions: testActions, sections: testSections, inviteMembersOpen }, + }); }; beforeEach(() => { @@ -17,6 +21,7 @@ describe('Learn GitLab', () => { afterEach(() => { wrapper.destroy(); wrapper = null; + inviteMembersOpen = false; }); it('renders correctly', () => { @@ -35,4 +40,30 @@ describe('Learn GitLab', () => { expect(progressBar.attributes('value')).toBe('2'); expect(progressBar.attributes('max')).toBe('9'); }); + + describe('Invite Members Modal', () => { + let spy; + + beforeEach(() => { + spy = jest.spyOn(eventHub, '$emit'); + }); + + it('emits openModal', () => { + inviteMembersOpen = true; + + createWrapper(); + + expect(spy).toHaveBeenCalledWith('openModal', { + mode: 'celebrate', + inviteeType: 'members', + source: 'learn-gitlab', + }); + }); + + it('does not emit openModal', () => { + createWrapper(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 082a8977710..9d510b3d231 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -8,9 +8,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import ContentEditor from '~/content_editor/components/content_editor.vue'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; import { - WIKI_CONTENT_EDITOR_TRACKING_LABEL, CONTENT_EDITOR_LOADED_ACTION, SAVED_USING_CONTENT_EDITOR_ACTION, + WIKI_CONTENT_EDITOR_TRACKING_LABEL, + WIKI_FORMAT_LABEL, + WIKI_FORMAT_UPDATED_ACTION, } from '~/pages/shared/wikis/constants'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; @@ -65,7 +67,6 @@ describe('WikiForm', () => { const pageInfoPersisted = { ...pageInfoNew, persisted: true, - title: 'My page', content: ' My page content ', format: 'markdown', @@ -177,7 +178,7 @@ describe('WikiForm', () => { await wrapper.vm.$nextTick(); expect(wrapper.text()).toContain(titleHelpText); - expect(findTitleHelpLink().attributes().href).toEqual(titleHelpLink); + expect(findTitleHelpLink().attributes().href).toBe(titleHelpLink); }, ); @@ -186,7 +187,7 @@ describe('WikiForm', () => { await wrapper.vm.$nextTick(); - expect(findMarkdownHelpLink().attributes().href).toEqual( + expect(findMarkdownHelpLink().attributes().href).toBe( '/help/user/markdown#wiki-specific-markdown', ); }); @@ -220,8 +221,8 @@ describe('WikiForm', () => { expect(e.preventDefault).not.toHaveBeenCalled(); }); - it('does not trigger tracking event', async () => { - expect(trackingSpy).not.toHaveBeenCalled(); + it('triggers wiki format tracking event', async () => { + expect(trackingSpy).toHaveBeenCalledTimes(1); }); it('does not trim page content', () => { @@ -273,7 +274,7 @@ describe('WikiForm', () => { ({ persisted, redirectLink }) => { createWrapper(persisted); - expect(findCancelButton().attributes().href).toEqual(redirectLink); + expect(findCancelButton().attributes().href).toBe(redirectLink); }, ); }); @@ -438,7 +439,7 @@ describe('WikiForm', () => { }); }); - it('triggers tracking event on form submit', async () => { + it('triggers tracking events on form submit', async () => { triggerFormSubmit(); await wrapper.vm.$nextTick(); @@ -446,6 +447,15 @@ describe('WikiForm', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, }); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { + label: WIKI_FORMAT_LABEL, + value: findFormat().element.value, + extra: { + old_format: pageInfoPersisted.format, + project_path: pageInfoPersisted.path, + }, + }); }); it('updates content from content editor on form submit', async () => { diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js index 8040c9d701c..23219042008 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js @@ -5,6 +5,9 @@ import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import { mockCommitMessage, mockDefaultBranch } from '../../mock_data'; +const scrollIntoViewMock = jest.fn(); +HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + describe('Pipeline Editor | Commit Form', () => { let wrapper; @@ -113,4 +116,20 @@ describe('Pipeline Editor | Commit Form', () => { expect(findSubmitBtn().attributes('disabled')).toBe('disabled'); }); }); + + describe('when scrollToCommitForm becomes true', () => { + beforeEach(async () => { + createComponent(); + wrapper.setProps({ scrollToCommitForm: true }); + await wrapper.vm.$nextTick(); + }); + + it('scrolls into view', () => { + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' }); + }); + + it('emits "scrolled-to-commit-form"', () => { + expect(wrapper.emitted()['scrolled-to-commit-form']).toBeTruthy(); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js index 2f934898ef1..efc345d8877 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js @@ -52,6 +52,7 @@ describe('Pipeline Editor | Commit section', () => { const defaultProps = { ciFileContent: mockCiYml, commitSha: mockCommitSha, + isNewCiConfigFile: false, }; const createComponent = ({ props = {}, options = {}, provide = {} } = {}) => { @@ -72,7 +73,6 @@ describe('Pipeline Editor | Commit section', () => { data() { return { currentBranch: mockDefaultBranch, - isNewCiConfigFile: Boolean(options?.isNewCiConfigfile), }; }, mocks: { @@ -115,7 +115,7 @@ describe('Pipeline Editor | Commit section', () => { describe('when the user commits a new file', () => { beforeEach(async () => { - createComponent({ options: { isNewCiConfigfile: true } }); + createComponent({ props: { isNewCiConfigFile: true } }); await submitCommit(); }); @@ -277,4 +277,16 @@ describe('Pipeline Editor | Commit section', () => { expect(wrapper.emitted('resetContent')).toHaveLength(1); }); }); + + it('sets listeners on commit form', () => { + const handler = jest.fn(); + createComponent({ options: { listeners: { event: handler } } }); + findCommitForm().vm.$emit('event'); + expect(handler).toHaveBeenCalled(); + }); + + it('passes down scroll-to-commit-form prop to commit form', () => { + createComponent({ props: { 'scroll-to-commit-form': true } }); + expect(findCommitForm().props('scrollToCommitForm')).toBe(true); + }); }); diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js index 1b68cd3dc43..4df7768b035 100644 --- a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js +++ b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js @@ -1,6 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { stubExperiments } from 'helpers/experimentation_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; @@ -33,19 +34,41 @@ describe('Pipeline editor drawer', () => { const clickToggleBtn = async () => findToggleBtn().vm.$emit('click'); + const originalObjects = []; + + beforeEach(() => { + originalObjects.push(window.gon, window.gl); + stubExperiments({ pipeline_editor_walkthrough: 'control' }); + }); + afterEach(() => { wrapper.destroy(); localStorage.clear(); + [window.gon, window.gl] = originalObjects; }); - it('it sets the drawer to be opened by default', async () => { - createComponent(); - - expect(findDrawerContent().exists()).toBe(false); - - await nextTick(); + describe('default expanded state', () => { + describe('when experiment control', () => { + it('sets the drawer to be opened by default', async () => { + createComponent(); + expect(findDrawerContent().exists()).toBe(false); + await nextTick(); + expect(findDrawerContent().exists()).toBe(true); + }); + }); - expect(findDrawerContent().exists()).toBe(true); + describe('when experiment candidate', () => { + beforeEach(() => { + stubExperiments({ pipeline_editor_walkthrough: 'candidate' }); + }); + + it('sets the drawer to be closed by default', async () => { + createComponent(); + expect(findDrawerContent().exists()).toBe(false); + await nextTick(); + expect(findDrawerContent().exists()).toBe(false); + }); + }); }); describe('when the drawer is collapsed', () => { diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index b5881790b0b..6532c4e289d 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -36,8 +36,9 @@ describe('Pipeline editor branch switcher', () => { let mockLastCommitBranchQuery; const createComponent = ( - { currentBranch, isQueryLoading, mountFn, options } = { + { currentBranch, isQueryLoading, mountFn, options, props } = { currentBranch: mockDefaultBranch, + hasUnsavedChanges: false, isQueryLoading: false, mountFn: shallowMount, options: {}, @@ -45,6 +46,7 @@ describe('Pipeline editor branch switcher', () => { ) => { wrapper = mountFn(BranchSwitcher, { propsData: { + ...props, paginationLimit: mockBranchPaginationLimit, }, provide: { @@ -70,7 +72,7 @@ describe('Pipeline editor branch switcher', () => { }); }; - const createComponentWithApollo = (mountFn = shallowMount) => { + const createComponentWithApollo = ({ mountFn = shallowMount, props = {} } = {}) => { const handlers = [[getAvailableBranchesQuery, mockAvailableBranchQuery]]; const resolvers = { Query: { @@ -86,6 +88,7 @@ describe('Pipeline editor branch switcher', () => { createComponent({ mountFn, + props, options: { localVue, apolloProvider: mockApollo, @@ -138,8 +141,8 @@ describe('Pipeline editor branch switcher', () => { createComponentWithApollo(); }); - it('does not render dropdown', () => { - expect(findDropdown().exists()).toBe(false); + it('disables the dropdown', () => { + expect(findDropdown().props('disabled')).toBe(true); }); }); @@ -149,7 +152,7 @@ describe('Pipeline editor branch switcher', () => { availableBranches: mockProjectBranches, currentBranch: mockDefaultBranch, }); - createComponentWithApollo(mount); + createComponentWithApollo({ mountFn: mount }); await waitForPromises(); }); @@ -186,7 +189,7 @@ describe('Pipeline editor branch switcher', () => { }); it('does not render dropdown', () => { - expect(findDropdown().exists()).toBe(false); + expect(findDropdown().props('disabled')).toBe(true); }); it('shows an error message', () => { @@ -201,7 +204,7 @@ describe('Pipeline editor branch switcher', () => { availableBranches: mockProjectBranches, currentBranch: mockDefaultBranch, }); - createComponentWithApollo(mount); + createComponentWithApollo({ mountFn: mount }); await waitForPromises(); }); @@ -247,6 +250,23 @@ describe('Pipeline editor branch switcher', () => { expect(wrapper.emitted('refetchContent')).toBeUndefined(); }); + + describe('with unsaved changes', () => { + beforeEach(async () => { + createComponentWithApollo({ mountFn: mount, props: { hasUnsavedChanges: true } }); + await waitForPromises(); + }); + + it('emits `select-branch` event and does not switch branch', async () => { + expect(wrapper.emitted('select-branch')).toBeUndefined(); + + const branch = findDropdownItems().at(1); + await branch.vm.$emit('click'); + + expect(wrapper.emitted('select-branch')).toEqual([[branch.text()]]); + expect(wrapper.emitted('refetchContent')).toBeUndefined(); + }); + }); }); describe('when searching', () => { @@ -255,7 +275,7 @@ describe('Pipeline editor branch switcher', () => { availableBranches: mockProjectBranches, currentBranch: mockDefaultBranch, }); - createComponentWithApollo(mount); + createComponentWithApollo({ mountFn: mount }); await waitForPromises(); }); @@ -429,7 +449,7 @@ describe('Pipeline editor branch switcher', () => { availableBranches: mockProjectBranches, currentBranch: mockDefaultBranch, }); - createComponentWithApollo(mount); + createComponentWithApollo({ mountFn: mount }); await waitForPromises(); await createNewBranch(); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js index 44656b2b67d..29ab52bde8f 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -16,7 +16,7 @@ describe('Pipeline Status', () => { let mockApollo; let mockPipelineQuery; - const createComponentWithApollo = (glFeatures = {}) => { + const createComponentWithApollo = () => { const handlers = [[getPipelineQuery, mockPipelineQuery]]; mockApollo = createMockApollo(handlers); @@ -27,7 +27,6 @@ describe('Pipeline Status', () => { commitSha: mockCommitSha, }, provide: { - glFeatures, projectFullPath: mockProjectFullPath, }, stubs: { GlLink, GlSprintf }, @@ -40,6 +39,8 @@ describe('Pipeline Status', () => { const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]'); const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]'); const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]'); + const findPipelineNotTriggeredErrorMsg = () => + wrapper.find('[data-testid="pipeline-not-triggered-error-msg"]'); const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]'); const findPipelineViewBtn = () => wrapper.find('[data-testid="pipeline-view-btn"]'); const findStatusIcon = () => wrapper.find('[data-testid="pipeline-status-icon"]'); @@ -95,17 +96,18 @@ describe('Pipeline Status', () => { it('renders pipeline data', () => { const { id, + commit: { title }, detailedStatus: { detailsPath }, } = mockProjectPipeline().pipeline; expect(findStatusIcon().exists()).toBe(true); expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`); - expect(findPipelineCommit().text()).toBe(mockCommitSha); + expect(findPipelineCommit().text()).toBe(`${mockCommitSha}: ${title}`); expect(findPipelineViewBtn().attributes('href')).toBe(detailsPath); }); - it('does not render the pipeline mini graph', () => { - expect(findPipelineEditorMiniGraph().exists()).toBe(false); + it('renders the pipeline mini graph', () => { + expect(findPipelineEditorMiniGraph().exists()).toBe(true); }); }); @@ -117,7 +119,8 @@ describe('Pipeline Status', () => { await waitForPromises(); }); - it('renders error', () => { + it('renders api error', () => { + expect(findPipelineNotTriggeredErrorMsg().exists()).toBe(false); expect(findIcon().attributes('name')).toBe('warning-solid'); expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError); }); @@ -129,20 +132,22 @@ describe('Pipeline Status', () => { expect(findPipelineViewBtn().exists()).toBe(false); }); }); - }); - describe('when feature flag for pipeline mini graph is enabled', () => { - beforeEach(() => { - mockPipelineQuery.mockResolvedValue({ - data: { project: mockProjectPipeline() }, - }); + describe('when pipeline is null', () => { + beforeEach(() => { + mockPipelineQuery.mockResolvedValue({ + data: { project: { pipeline: null } }, + }); - createComponentWithApollo({ pipelineEditorMiniGraph: true }); - waitForPromises(); - }); + createComponentWithApollo(); + waitForPromises(); + }); - it('renders the pipeline mini graph', () => { - expect(findPipelineEditorMiniGraph().exists()).toBe(true); + it('renders pipeline not triggered error', () => { + expect(findPipelineErrorMsg().exists()).toBe(false); + expect(findIcon().attributes('name')).toBe('information-o'); + expect(findPipelineNotTriggeredErrorMsg().text()).toBe(i18n.pipelineNotTriggeredMsg); + }); }); }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js b/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js index 3d7c3c839da..6b9f576917f 100644 --- a/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js @@ -1,22 +1,54 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; -import { mockProjectPipeline } from '../../mock_data'; +import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import { PIPELINE_FAILURE } from '~/pipeline_editor/constants'; +import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Pipeline Status', () => { let wrapper; + let mockApollo; + let mockLinkedPipelinesQuery; - const createComponent = ({ hasStages = true } = {}) => { + const createComponent = ({ hasStages = true, options } = {}) => { wrapper = shallowMount(PipelineEditorMiniGraph, { + provide: { + dataMethod: 'graphql', + projectFullPath: mockProjectFullPath, + }, propsData: { pipeline: mockProjectPipeline({ hasStages }).pipeline, }, + ...options, + }); + }; + + const createComponentWithApollo = (hasStages = true) => { + const handlers = [[getLinkedPipelinesQuery, mockLinkedPipelinesQuery]]; + mockApollo = createMockApollo(handlers); + + createComponent({ + hasStages, + options: { + localVue, + apolloProvider: mockApollo, + }, }); }; const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + beforeEach(() => { + mockLinkedPipelinesQuery = jest.fn(); + }); + afterEach(() => { + mockLinkedPipelinesQuery.mockReset(); wrapper.destroy(); }); @@ -39,4 +71,38 @@ describe('Pipeline Status', () => { expect(findPipelineMiniGraph().exists()).toBe(false); }); }); + + describe('when querying upstream and downstream pipelines', () => { + describe('when query succeeds', () => { + beforeEach(() => { + mockLinkedPipelinesQuery.mockResolvedValue(mockLinkedPipelines()); + createComponentWithApollo(); + }); + + it('should call the query with the correct variables', () => { + expect(mockLinkedPipelinesQuery).toHaveBeenCalledTimes(1); + expect(mockLinkedPipelinesQuery).toHaveBeenCalledWith({ + fullPath: mockProjectFullPath, + iid: mockProjectPipeline().pipeline.iid, + }); + }); + }); + + describe('when query fails', () => { + beforeEach(() => { + mockLinkedPipelinesQuery.mockRejectedValue(new Error()); + createComponentWithApollo(); + }); + + it('should emit an error event when query fails', async () => { + expect(wrapper.emitted('showError')).toHaveLength(1); + expect(wrapper.emitted('showError')[0]).toEqual([ + { + type: PIPELINE_FAILURE, + reasons: [wrapper.vm.$options.i18n.linkedPipelinesFetchError], + }, + ]); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index 5cf8d47bc23..f6154f50bc0 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -1,19 +1,27 @@ -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import setWindowLocation from 'helpers/set_window_location_helper'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; +import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; +import { stubExperiments } from 'helpers/experimentation_helper'; import { + CREATE_TAB, EDITOR_APP_STATUS_EMPTY, - EDITOR_APP_STATUS_ERROR, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_VALID, + MERGED_TAB, + TAB_QUERY_PARAM, + TABS_INDEX, } from '~/pipeline_editor/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import { mockLintResponse, mockCiYml } from '../mock_data'; +import { mockLintResponse, mockLintResponseWithoutMerged, mockCiYml } from '../mock_data'; + +Vue.config.ignoredElements = ['gl-emoji']; describe('Pipeline editor tabs component', () => { let wrapper; @@ -22,6 +30,7 @@ describe('Pipeline editor tabs component', () => { }; const createComponent = ({ + listeners = {}, props = {}, provide = {}, appStatus = EDITOR_APP_STATUS_VALID, @@ -31,6 +40,7 @@ describe('Pipeline editor tabs component', () => { propsData: { ciConfigData: mockLintResponse, ciFileContent: mockCiYml, + isNewCiConfigFile: true, ...props, }, data() { @@ -43,6 +53,7 @@ describe('Pipeline editor tabs component', () => { TextEditor: MockTextEditor, EditorTab, }, + listeners, }); }; @@ -53,10 +64,12 @@ describe('Pipeline editor tabs component', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findCiLint = () => wrapper.findComponent(CiLint); + const findGlTabs = () => wrapper.findComponent(GlTabs); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPipelineGraph = () => wrapper.findComponent(PipelineGraph); const findTextEditor = () => wrapper.findComponent(MockTextEditor); const findMergedPreview = () => wrapper.findComponent(CiConfigMergedPreview); + const findWalkthroughPopover = () => wrapper.findComponent(WalkthroughPopover); afterEach(() => { wrapper.destroy(); @@ -137,7 +150,7 @@ describe('Pipeline editor tabs component', () => { describe('when there is a fetch error', () => { beforeEach(() => { - createComponent({ appStatus: EDITOR_APP_STATUS_ERROR }); + createComponent({ props: { ciConfigData: mockLintResponseWithoutMerged } }); }); it('show an error message', () => { @@ -181,4 +194,113 @@ describe('Pipeline editor tabs component', () => { }, ); }); + + describe('default tab based on url query param', () => { + const gitlabUrl = 'https://gitlab.test/ci/editor/'; + const matchObject = { + hostname: 'gitlab.test', + pathname: '/ci/editor/', + search: '', + }; + + it(`is ${CREATE_TAB} if the query param ${TAB_QUERY_PARAM} is not present`, () => { + setWindowLocation(gitlabUrl); + createComponent(); + + expect(window.location).toMatchObject(matchObject); + }); + + it(`is ${CREATE_TAB} tab if the query param ${TAB_QUERY_PARAM} is invalid`, () => { + const queryValue = 'FOO'; + setWindowLocation(`${gitlabUrl}?${TAB_QUERY_PARAM}=${queryValue}`); + createComponent(); + + // If the query param remains unchanged, then we have ignored it. + expect(window.location).toMatchObject({ + ...matchObject, + search: `?${TAB_QUERY_PARAM}=${queryValue}`, + }); + }); + + it('is the tab specified in query param and transform it into an index value', async () => { + setWindowLocation(`${gitlabUrl}?${TAB_QUERY_PARAM}=${MERGED_TAB}`); + createComponent(); + + // If the query param has changed to an index, it means we have synced the + // query with. + expect(window.location).toMatchObject({ + ...matchObject, + search: `?${TAB_QUERY_PARAM}=${TABS_INDEX[MERGED_TAB]}`, + }); + }); + }); + + describe('glTabs', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes the `sync-active-tab-with-query-params` prop', () => { + expect(findGlTabs().props('syncActiveTabWithQueryParams')).toBe(true); + }); + }); + + describe('pipeline_editor_walkthrough experiment', () => { + describe('when in control path', () => { + beforeEach(() => { + stubExperiments({ pipeline_editor_walkthrough: 'control' }); + }); + + it('does not show walkthrough popover', async () => { + createComponent({ mountFn: mount }); + await nextTick(); + expect(findWalkthroughPopover().exists()).toBe(false); + }); + }); + + describe('when in candidate path', () => { + beforeEach(() => { + stubExperiments({ pipeline_editor_walkthrough: 'candidate' }); + }); + + describe('when isNewCiConfigFile prop is true (default)', () => { + beforeEach(async () => { + createComponent({ + mountFn: mount, + }); + await nextTick(); + }); + + it('shows walkthrough popover', async () => { + expect(findWalkthroughPopover().exists()).toBe(true); + }); + }); + + describe('when isNewCiConfigFile prop is false', () => { + it('does not show walkthrough popover', async () => { + createComponent({ props: { isNewCiConfigFile: false }, mountFn: mount }); + await nextTick(); + expect(findWalkthroughPopover().exists()).toBe(false); + }); + }); + }); + }); + + it('sets listeners on walkthrough popover', async () => { + stubExperiments({ pipeline_editor_walkthrough: 'candidate' }); + + const handler = jest.fn(); + + createComponent({ + mountFn: mount, + listeners: { + event: handler, + }, + }); + await nextTick(); + + findWalkthroughPopover().vm.$emit('event'); + + expect(handler).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js index 9f910ed4f9c..a55176ccd79 100644 --- a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js @@ -11,6 +11,7 @@ import { DEFAULT_FAILURE, DEFAULT_SUCCESS, LOAD_FAILURE_UNKNOWN, + PIPELINE_FAILURE, } from '~/pipeline_editor/constants'; beforeEach(() => { @@ -65,6 +66,7 @@ describe('Pipeline Editor messages', () => { failureType | message | expectedFailureType ${COMMIT_FAILURE} | ${'failed commit'} | ${COMMIT_FAILURE} ${LOAD_FAILURE_UNKNOWN} | ${'loading failure'} | ${LOAD_FAILURE_UNKNOWN} + ${PIPELINE_FAILURE} | ${'pipeline failure'} | ${PIPELINE_FAILURE} ${'random'} | ${'error without a specified type'} | ${DEFAULT_FAILURE} `('shows a message for $message', ({ failureType, expectedFailureType }) => { createComponent({ failureType, showFailure: true }); diff --git a/spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js b/spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js new file mode 100644 index 00000000000..a9ce89ff521 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js @@ -0,0 +1,29 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +Vue.config.ignoredElements = ['gl-emoji']; + +describe('WalkthroughPopover component', () => { + let wrapper; + + const createComponent = (mountFn = shallowMount) => { + return extendedWrapper(mountFn(WalkthroughPopover)); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('CTA button clicked', () => { + beforeEach(async () => { + wrapper = createComponent(mount); + await wrapper.findByTestId('ctaBtn').trigger('click'); + }); + + it('emits "walkthrough-popover-cta-clicked" event', async () => { + expect(wrapper.emitted()['walkthrough-popover-cta-clicked']).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 0b0ff14486e..1bfc5c3b93d 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -1,4 +1,4 @@ -import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; +import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; export const mockProjectNamespace = 'user1'; @@ -35,6 +35,17 @@ job_build: - echo "build" needs: ["job_test_2"] `; + +export const mockCiTemplateQueryResponse = { + data: { + project: { + ciTemplate: { + content: mockCiYml, + }, + }, + }, +}; + export const mockBlobContentQueryResponse = { data: { project: { repository: { blobs: { nodes: [{ rawBlob: mockCiYml }] } } }, @@ -274,11 +285,14 @@ export const mockProjectPipeline = ({ hasStages = true } = {}) => { return { pipeline: { - commitPath: '/-/commit/aabbccdd', id: 'gid://gitlab/Ci::Pipeline/118', iid: '28', shortSha: mockCommitSha, status: 'SUCCESS', + commit: { + title: 'Update .gitlabe-ci.yml', + webPath: '/-/commit/aabbccdd', + }, detailedStatus: { detailsPath: '/root/sample-ci-project/-/pipelines/118', group: 'success', @@ -290,6 +304,62 @@ export const mockProjectPipeline = ({ hasStages = true } = {}) => { }; }; +export const mockLinkedPipelines = ({ hasDownstream = true, hasUpstream = true } = {}) => { + let upstream = null; + let downstream = { + nodes: [], + __typename: 'PipelineConnection', + }; + + if (hasDownstream) { + downstream = { + nodes: [ + { + id: 'gid://gitlab/Ci::Pipeline/612', + path: '/root/job-log-sections/-/pipelines/612', + project: { name: 'job-log-sections', __typename: 'Project' }, + detailedStatus: { + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }, + ], + __typename: 'PipelineConnection', + }; + } + + if (hasUpstream) { + upstream = { + id: 'gid://gitlab/Ci::Pipeline/610', + path: '/root/trigger-downstream/-/pipelines/610', + project: { name: 'trigger-downstream', __typename: 'Project' }, + detailedStatus: { + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }; + } + + return { + data: { + project: { + pipeline: { + path: '/root/ci-project/-/pipelines/790', + downstream, + upstream, + }, + __typename: 'Project', + }, + }, + }; +}; + export const mockLintResponse = { valid: true, mergedYaml: mockCiYml, @@ -326,6 +396,14 @@ export const mockLintResponse = { ], }; +export const mockLintResponseWithoutMerged = { + valid: false, + status: CI_CONFIG_STATUS_INVALID, + errors: ['error'], + warnings: [], + jobs: [], +}; + export const mockJobs = [ { name: 'job_1', diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index b6713319e69..f6afef595c6 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -1,11 +1,9 @@ -import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; -import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; @@ -13,17 +11,21 @@ import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_edi import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants'; import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.graphql'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql'; -import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql'; import getLatestCommitShaQuery from '~/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql'; + +import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; + import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; + import { mockCiConfigPath, mockCiConfigQueryResponse, mockBlobContentQueryResponse, mockBlobContentQueryResponseNoCiFile, mockCiYml, + mockCiTemplateQueryResponse, mockCommitSha, mockCommitShaResults, mockDefaultBranch, @@ -35,10 +37,6 @@ import { const localVue = createLocalVue(); localVue.use(VueApollo); -const MockSourceEditor = { - template: '
    ', -}; - const mockProvide = { ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, @@ -55,19 +53,15 @@ describe('Pipeline editor app component', () => { let mockLatestCommitShaQuery; let mockPipelineQuery; - const createComponent = ({ blobLoading = false, options = {}, provide = {} } = {}) => { + const createComponent = ({ + blobLoading = false, + options = {}, + provide = {}, + stubs = {}, + } = {}) => { wrapper = shallowMount(PipelineEditorApp, { provide: { ...mockProvide, ...provide }, - stubs: { - GlTabs, - GlButton, - CommitForm, - PipelineEditorHome, - PipelineEditorTabs, - PipelineEditorMessages, - SourceEditor: MockSourceEditor, - PipelineEditorEmptyState, - }, + stubs, data() { return { commitSha: '', @@ -89,7 +83,7 @@ describe('Pipeline editor app component', () => { }); }; - const createComponentWithApollo = async ({ props = {}, provide = {} } = {}) => { + const createComponentWithApollo = async ({ provide = {}, stubs = {} } = {}) => { const handlers = [ [getBlobContent, mockBlobContentData], [getCiConfigData, mockCiConfigData], @@ -97,7 +91,6 @@ describe('Pipeline editor app component', () => { [getLatestCommitShaQuery, mockLatestCommitShaQuery], [getPipelineQuery, mockPipelineQuery], ]; - mockApollo = createMockApollo(handlers); const options = { @@ -105,13 +98,15 @@ describe('Pipeline editor app component', () => { data() { return { currentBranch: mockDefaultBranch, + lastCommitBranch: '', + appStatus: '', }; }, mocks: {}, apolloProvider: mockApollo, }; - createComponent({ props, provide, options }); + createComponent({ provide, stubs, options }); return waitForPromises(); }; @@ -119,7 +114,6 @@ describe('Pipeline editor app component', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); const findEditorHome = () => wrapper.findComponent(PipelineEditorHome); - const findTextEditor = () => wrapper.findComponent(TextEditor); const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); const findEmptyStateButton = () => wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); @@ -141,7 +135,7 @@ describe('Pipeline editor app component', () => { createComponent({ blobLoading: true }); expect(findLoadingIcon().exists()).toBe(true); - expect(findTextEditor().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(false); }); }); @@ -185,7 +179,11 @@ describe('Pipeline editor app component', () => { describe('when no CI config file exists', () => { beforeEach(async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); - await createComponentWithApollo(); + await createComponentWithApollo({ + stubs: { + PipelineEditorEmptyState, + }, + }); jest .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') @@ -206,8 +204,12 @@ describe('Pipeline editor app component', () => { it('shows a unkown error message', async () => { const loadUnknownFailureText = 'The CI configuration was not loaded, please try again.'; - mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); - await createComponentWithApollo(); + mockBlobContentData.mockRejectedValueOnce(); + await createComponentWithApollo({ + stubs: { + PipelineEditorMessages, + }, + }); expect(findEmptyState().exists()).toBe(false); @@ -222,15 +224,20 @@ describe('Pipeline editor app component', () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); mockLatestCommitShaQuery.mockResolvedValue(mockEmptyCommitShaResults); - await createComponentWithApollo(); + await createComponentWithApollo({ + stubs: { + PipelineEditorHome, + PipelineEditorEmptyState, + }, + }); expect(findEmptyState().exists()).toBe(true); - expect(findTextEditor().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(false); await findEmptyStateButton().vm.$emit('click'); expect(findEmptyState().exists()).toBe(false); - expect(findTextEditor().exists()).toBe(true); + expect(findEditorHome().exists()).toBe(true); }); }); @@ -241,7 +248,7 @@ describe('Pipeline editor app component', () => { describe('and the commit mutation succeeds', () => { beforeEach(async () => { window.scrollTo = jest.fn(); - await createComponentWithApollo(); + await createComponentWithApollo({ stubs: { PipelineEditorMessages } }); findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS }); }); @@ -295,7 +302,7 @@ describe('Pipeline editor app component', () => { beforeEach(async () => { window.scrollTo = jest.fn(); - await createComponentWithApollo(); + await createComponentWithApollo({ stubs: { PipelineEditorMessages } }); findEditorHome().vm.$emit('showError', { type: COMMIT_FAILURE, @@ -319,7 +326,7 @@ describe('Pipeline editor app component', () => { beforeEach(async () => { window.scrollTo = jest.fn(); - await createComponentWithApollo(); + await createComponentWithApollo({ stubs: { PipelineEditorMessages } }); findEditorHome().vm.$emit('showError', { type: COMMIT_FAILURE, @@ -342,6 +349,8 @@ describe('Pipeline editor app component', () => { describe('when refetching content', () => { beforeEach(() => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); }); @@ -377,7 +386,10 @@ describe('Pipeline editor app component', () => { const originalLocation = window.location.href; beforeEach(() => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); + mockGetTemplate.mockResolvedValue(mockCiTemplateQueryResponse); setWindowLocation('?template=Android'); }); @@ -386,7 +398,9 @@ describe('Pipeline editor app component', () => { }); it('renders the given template', async () => { - await createComponentWithApollo(); + await createComponentWithApollo({ + stubs: { PipelineEditorHome, PipelineEditorTabs }, + }); expect(mockGetTemplate).toHaveBeenCalledWith({ projectPath: mockProjectFullPath, @@ -394,7 +408,40 @@ describe('Pipeline editor app component', () => { }); expect(findEmptyState().exists()).toBe(false); - expect(findTextEditor().exists()).toBe(true); + expect(findEditorHome().exists()).toBe(true); + }); + }); + + describe('when add_new_config_file query param is present', () => { + const originalLocation = window.location.href; + + beforeEach(() => { + setWindowLocation('?add_new_config_file=true'); + + mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); + }); + + afterEach(() => { + setWindowLocation(originalLocation); + }); + + describe('when CI config file does not exist', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); + mockLatestCommitShaQuery.mockResolvedValue(mockEmptyCommitShaResults); + mockGetTemplate.mockResolvedValue(mockCiTemplateQueryResponse); + + await createComponentWithApollo(); + + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') + .mockImplementation(jest.fn()); + }); + + it('skips empty state and shows editor home component', () => { + expect(findEmptyState().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(true); + }); }); }); }); diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index 335049892ec..6f969546171 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -1,21 +1,25 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; - +import { GlModal } from '@gitlab/ui'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; +import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; -import { MERGED_TAB, VISUALIZE_TAB } from '~/pipeline_editor/constants'; +import { MERGED_TAB, VISUALIZE_TAB, CREATE_TAB, LINT_TAB } from '~/pipeline_editor/constants'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; import { mockLintResponse, mockCiYml } from './mock_data'; +jest.mock('~/lib/utils/common_utils'); + describe('Pipeline editor home wrapper', () => { let wrapper; - const createComponent = ({ props = {}, glFeatures = {} } = {}) => { + const createComponent = ({ props = {}, glFeatures = {}, data = {}, stubs = {} } = {}) => { wrapper = shallowMount(PipelineEditorHome, { + data: () => data, propsData: { ciConfigData: mockLintResponse, ciFileContent: mockCiYml, @@ -24,22 +28,26 @@ describe('Pipeline editor home wrapper', () => { ...props, }, provide: { + projectFullPath: '', + totalBranches: 19, glFeatures: { ...glFeatures, }, }, + stubs, }); }; + const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher); const findCommitSection = () => wrapper.findComponent(CommitSection); const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav); + const findModal = () => wrapper.findComponent(GlModal); const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer); const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('renders', () => { @@ -68,29 +76,103 @@ describe('Pipeline editor home wrapper', () => { }); }); + describe('modal when switching branch', () => { + describe('when `showSwitchBranchModal` value is false', () => { + beforeEach(() => { + createComponent(); + }); + + it('is not visible', () => { + expect(findModal().exists()).toBe(false); + }); + }); + describe('when `showSwitchBranchModal` value is true', () => { + beforeEach(() => { + createComponent({ + data: { showSwitchBranchModal: true }, + stubs: { PipelineEditorFileNav }, + }); + }); + + it('is visible', () => { + expect(findModal().exists()).toBe(true); + }); + + it('pass down `shouldLoadNewBranch` to the branch switcher when primary is selected', async () => { + expect(findBranchSwitcher().props('shouldLoadNewBranch')).toBe(false); + + await findModal().vm.$emit('primary'); + + expect(findBranchSwitcher().props('shouldLoadNewBranch')).toBe(true); + }); + + it('closes the modal when secondary action is selected', async () => { + expect(findModal().exists()).toBe(true); + + await findModal().vm.$emit('secondary'); + + expect(findModal().exists()).toBe(false); + }); + }); + }); + describe('commit form toggle', () => { beforeEach(() => { createComponent(); }); - it('hides the commit form when in the merged tab', async () => { - expect(findCommitSection().exists()).toBe(true); + it.each` + tab | shouldShow + ${MERGED_TAB} | ${false} + ${VISUALIZE_TAB} | ${false} + ${LINT_TAB} | ${false} + ${CREATE_TAB} | ${true} + `( + 'when the active tab is $tab the commit form is shown: $shouldShow', + async ({ tab, shouldShow }) => { + expect(findCommitSection().exists()).toBe(true); - findPipelineEditorTabs().vm.$emit('set-current-tab', MERGED_TAB); - await nextTick(); - expect(findCommitSection().exists()).toBe(false); - }); + findPipelineEditorTabs().vm.$emit('set-current-tab', tab); + + await nextTick(); - it('shows the form again when leaving the merged tab', async () => { + expect(findCommitSection().exists()).toBe(shouldShow); + }, + ); + + it('shows the commit form again when coming back to the create tab', async () => { expect(findCommitSection().exists()).toBe(true); findPipelineEditorTabs().vm.$emit('set-current-tab', MERGED_TAB); await nextTick(); expect(findCommitSection().exists()).toBe(false); - findPipelineEditorTabs().vm.$emit('set-current-tab', VISUALIZE_TAB); + findPipelineEditorTabs().vm.$emit('set-current-tab', CREATE_TAB); await nextTick(); expect(findCommitSection().exists()).toBe(true); }); }); + + describe('WalkthroughPopover events', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when "walkthrough-popover-cta-clicked" is emitted from pipeline editor tabs', () => { + it('passes down `scrollToCommitForm=true` to commit section', async () => { + expect(findCommitSection().props('scrollToCommitForm')).toBe(false); + await findPipelineEditorTabs().vm.$emit('walkthrough-popover-cta-clicked'); + expect(findCommitSection().props('scrollToCommitForm')).toBe(true); + }); + }); + + describe('when "scrolled-to-commit-form" is emitted from commit section', () => { + it('passes down `scrollToCommitForm=false` to commit section', async () => { + await findPipelineEditorTabs().vm.$emit('walkthrough-popover-cta-clicked'); + expect(findCommitSection().props('scrollToCommitForm')).toBe(true); + await findCommitSection().vm.$emit('scrolled-to-commit-form'); + expect(findCommitSection().props('scrollToCommitForm')).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 1af3065477d..31b74a06efd 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -35,7 +35,7 @@ describe('Pipelines Empty State', () => { }); it('should render the CI/CD templates', () => { - expect(pipelinesCiTemplates()).toExist(); + expect(pipelinesCiTemplates().exists()).toBe(true); }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 2e8979f2b9d..db4de6deeb7 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -327,7 +327,7 @@ describe('Pipeline graph wrapper', () => { expect(getLinksLayer().exists()).toBe(true); expect(getLinksLayer().props('showLinks')).toBe(false); expect(getViewSelector().props('type')).toBe(LAYER_VIEW); - await getDependenciesToggle().trigger('click'); + await getDependenciesToggle().vm.$emit('change', true); jest.runOnlyPendingTimers(); await wrapper.vm.$nextTick(); expect(wrapper.findComponent(LinksLayer).props('showLinks')).toBe(true); diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js index 5b2a29de443..f4faa25545b 100644 --- a/spec/frontend/pipelines/graph/graph_view_selector_spec.js +++ b/spec/frontend/pipelines/graph/graph_view_selector_spec.js @@ -111,7 +111,7 @@ describe('the graph view selector component', () => { expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); expect(findToggleLoader().exists()).toBe(false); - await findDependenciesToggle().trigger('click'); + await findDependenciesToggle().vm.$emit('change', true); /* Loading happens before the event is emitted or timers are run. Then we run the timer because the event is emitted in setInterval diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index f33c66dedf3..2d876841e06 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -1,15 +1,9 @@ -import { GlAlert, GlDropdown, GlDropdownItem, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import PipelineArtifacts, { - i18n, -} from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; +import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; describe('Pipelines Artifacts dropdown', () => { let wrapper; - let mockAxios; const artifacts = [ { @@ -21,23 +15,13 @@ describe('Pipelines Artifacts dropdown', () => { path: '/download/path-two', }, ]; - const artifactsEndpointPlaceholder = ':pipeline_artifacts_id'; - const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`; const pipelineId = 108; - const createComponent = ({ mockData = {} } = {}) => { + const createComponent = ({ mockArtifacts = artifacts } = {}) => { wrapper = shallowMount(PipelineArtifacts, { - provide: { - artifactsEndpoint, - artifactsEndpointPlaceholder, - }, propsData: { pipelineId, - }, - data() { - return { - ...mockData, - }; + artifacts: mockArtifacts, }, stubs: { GlSprintf, @@ -45,80 +29,33 @@ describe('Pipelines Artifacts dropdown', () => { }); }; - const findAlert = () => wrapper.findComponent(GlAlert); const findDropdown = () => wrapper.findComponent(GlDropdown); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findFirstGlDropdownItem = () => wrapper.find(GlDropdownItem); const findAllGlDropdownItems = () => wrapper.find(GlDropdown).findAll(GlDropdownItem); - beforeEach(() => { - mockAxios = new MockAdapter(axios); - }); - afterEach(() => { wrapper.destroy(); wrapper = null; }); - it('should render the dropdown', () => { - createComponent(); - - expect(findDropdown().exists()).toBe(true); - }); - - it('should fetch artifacts on dropdown click', async () => { - const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); - mockAxios.onGet(endpoint).replyOnce(200, { artifacts }); - createComponent(); - findDropdown().vm.$emit('show'); - await waitForPromises(); - - expect(mockAxios.history.get).toHaveLength(1); - expect(wrapper.vm.artifacts).toEqual(artifacts); - }); - it('should render a dropdown with all the provided artifacts', () => { - createComponent({ mockData: { artifacts } }); + createComponent(); expect(findAllGlDropdownItems()).toHaveLength(artifacts.length); }); it('should render a link with the provided path', () => { - createComponent({ mockData: { artifacts } }); + createComponent(); expect(findFirstGlDropdownItem().attributes('href')).toBe(artifacts[0].path); expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name); }); - describe('with a failing request', () => { - it('should render an error message', async () => { - const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); - mockAxios.onGet(endpoint).replyOnce(500); - createComponent(); - findDropdown().vm.$emit('show'); - await waitForPromises(); - - const error = findAlert(); - expect(error.exists()).toBe(true); - expect(error.text()).toBe(i18n.artifactsFetchErrorMessage); - }); - }); - - describe('with no artifacts received', () => { - it('should render empty alert message', () => { - createComponent({ mockData: { artifacts: [] } }); - - const emptyAlert = findAlert(); - expect(emptyAlert.exists()).toBe(true); - expect(emptyAlert.text()).toBe(i18n.noArtifacts); - }); - }); - - describe('when artifacts are loading', () => { - it('should show loading icon', () => { - createComponent({ mockData: { isLoading: true } }); + describe('with no artifacts', () => { + it('should not render the dropdown', () => { + createComponent({ mockArtifacts: [] }); - expect(findLoadingIcon().exists()).toBe(true); + expect(findDropdown().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 2875498bb52..c024730570c 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -554,7 +554,7 @@ describe('Pipelines', () => { }); it('renders the CI/CD templates', () => { - expect(wrapper.find(PipelinesCiTemplates)).toExist(); + expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true); }); describe('when the code_quality_walkthrough experiment is active', () => { @@ -568,7 +568,7 @@ describe('Pipelines', () => { }); it('renders the CI/CD templates', () => { - expect(wrapper.find(PipelinesCiTemplates)).toExist(); + expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true); }); }); @@ -597,7 +597,7 @@ describe('Pipelines', () => { }); it('renders the CI/CD templates', () => { - expect(wrapper.find(PipelinesCiTemplates)).toExist(); + expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true); }); }); diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index fb019b463b1..6fdbe907aed 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -1,5 +1,5 @@ import '~/commons'; -import { GlTable } from '@gitlab/ui'; +import { GlTableLite } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import fixture from 'test_fixtures/pipelines/pipelines.json'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -44,7 +44,7 @@ describe('Pipelines Table', () => { ); }; - const findGlTable = () => wrapper.findComponent(GlTable); + const findGlTableLite = () => wrapper.findComponent(GlTableLite); const findStatusBadge = () => wrapper.findComponent(CiBadge); const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); @@ -77,7 +77,7 @@ describe('Pipelines Table', () => { }); it('displays table', () => { - expect(findGlTable().exists()).toBe(true); + expect(findGlTableLite().exists()).toBe(true); }); it('should render table head with correct columns', () => { diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js index 0c8089430d0..93e2ae13628 100644 --- a/spec/frontend/projects/commit/components/form_modal_spec.js +++ b/spec/frontend/projects/commit/components/form_modal_spec.js @@ -3,6 +3,7 @@ import { within } from '@testing-library/dom'; import { shallowMount, mount, createWrapper } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import api from '~/api'; import axios from '~/lib/utils/axios_utils'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import BranchesDropdown from '~/projects/commit/components/branches_dropdown.vue'; @@ -12,6 +13,8 @@ import eventHub from '~/projects/commit/event_hub'; import createStore from '~/projects/commit/store'; import mockData from '../mock_data'; +jest.mock('~/api'); + describe('CommitFormModal', () => { let wrapper; let store; @@ -167,4 +170,16 @@ describe('CommitFormModal', () => { expect(findTargetProject().attributes('value')).toBe('_changed_project_value_'); }); }); + + it('action primary button triggers Redis HLL tracking api call', async () => { + createComponent(mount, {}, {}, { primaryActionEventName: 'test_event' }); + + await wrapper.vm.$nextTick(); + + jest.spyOn(findForm().element, 'submit'); + + getByText(mockData.modalPropsData.i18n.actionPrimaryText).trigger('click'); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_event'); + }); }); diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js index 9a8f7ff7582..60d36597fda 100644 --- a/spec/frontend/projects/commits/components/author_select_spec.js +++ b/spec/frontend/projects/commits/components/author_select_spec.js @@ -115,7 +115,7 @@ describe('Author Select', () => { }); it('does not have popover text by default', () => { - expect(wrapper.attributes('title')).not.toExist(); + expect(wrapper.attributes('title')).toBeUndefined(); }); }); diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap index c255fcce321..e1e1aac09aa 100644 --- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -52,9 +52,44 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` title="You are about to permanently delete this project" variant="danger" > - +

    + This project is + + NOT + + a fork, and has the following: +

    + +
      +
    • + 1 issue +
    • + +
    • + 2 merge requests +
    • + +
    • + 3 forks +
    • + +
    • + 4 stars +
    • +
    + After a project is permanently deleted, it + + cannot be recovered + + . Permanently deleting this project will + + immediately delete + + its repositories and + + all related resources + + , including issues, merge requests etc.

    { const defaultProps = { confirmPhrase: 'foo', formPath: 'some/path', + isFork: false, + issuesCount: 1, + mergeRequestsCount: 2, + forksCount: 3, + starsCount: 4, }; const createComponent = (props = {}) => { @@ -21,6 +27,7 @@ describe('Project remove modal', () => { ...props, }, stubs: { + GlSprintf, SharedDeleteButton, }, }); @@ -41,7 +48,10 @@ describe('Project remove modal', () => { }); it('passes confirmPhrase and formPath props to the shared delete button', () => { - expect(findSharedDeleteButton().props()).toEqual(defaultProps); + expect(findSharedDeleteButton().props()).toEqual({ + confirmPhrase: defaultProps.confirmPhrase, + formPath: defaultProps.formPath, + }); }); }); }); diff --git a/spec/frontend/projects/details/upload_button_spec.js b/spec/frontend/projects/details/upload_button_spec.js index ebb2b499ead..d7308963088 100644 --- a/spec/frontend/projects/details/upload_button_spec.js +++ b/spec/frontend/projects/details/upload_button_spec.js @@ -1,11 +1,8 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import UploadButton from '~/projects/details/upload_button.vue'; -import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; -jest.mock('~/projects/upload_file_experiment_tracking'); - const MODAL_ID = 'details-modal-upload-blob'; describe('UploadButton', () => { @@ -50,10 +47,6 @@ describe('UploadButton', () => { wrapper.find(GlButton).vm.$emit('click'); }); - it('tracks the click_upload_modal_trigger event', () => { - expect(trackFileUploadEvent).toHaveBeenCalledWith('click_upload_modal_trigger'); - }); - it('opens the modal', () => { expect(glModalDirective).toHaveBeenCalledWith(MODAL_ID); }); diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js index aa16b71172b..b3f177a1f12 100644 --- a/spec/frontend/projects/new/components/new_project_url_select_spec.js +++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js @@ -24,14 +24,23 @@ describe('NewProjectUrlSelect component', () => { { id: 'gid://gitlab/Group/26', fullPath: 'flightjs', + name: 'Flight JS', + visibility: 'public', + webUrl: 'http://127.0.0.1:3000/flightjs', }, { id: 'gid://gitlab/Group/28', fullPath: 'h5bp', + name: 'H5BP', + visibility: 'public', + webUrl: 'http://127.0.0.1:3000/h5bp', }, { id: 'gid://gitlab/Group/30', fullPath: 'h5bp/subgroup', + name: 'H5BP Subgroup', + visibility: 'private', + webUrl: 'http://127.0.0.1:3000/h5bp/subgroup', }, ], }, @@ -79,6 +88,10 @@ describe('NewProjectUrlSelect component', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findInput = () => wrapper.findComponent(GlSearchBoxByType); const findHiddenInput = () => wrapper.find('input'); + const clickDropdownItem = async () => { + wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + await wrapper.vm.$nextTick(); + }; afterEach(() => { wrapper.destroy(); @@ -127,7 +140,6 @@ describe('NewProjectUrlSelect component', () => { it('focuses on the input when the dropdown is opened', async () => { wrapper = mountComponent({ mountFn: mount }); - jest.runOnlyPendingTimers(); await wrapper.vm.$nextTick(); @@ -140,7 +152,6 @@ describe('NewProjectUrlSelect component', () => { it('renders expected dropdown items', async () => { wrapper = mountComponent({ mountFn: mount }); - jest.runOnlyPendingTimers(); await wrapper.vm.$nextTick(); @@ -160,7 +171,6 @@ describe('NewProjectUrlSelect component', () => { beforeEach(async () => { wrapper = mountComponent({ mountFn: mount }); - jest.runOnlyPendingTimers(); await wrapper.vm.$nextTick(); @@ -195,23 +205,38 @@ describe('NewProjectUrlSelect component', () => { }; wrapper = mountComponent({ search: 'no matches', queryResponse, mountFn: mount }); - jest.runOnlyPendingTimers(); await wrapper.vm.$nextTick(); expect(wrapper.find('li').text()).toBe('No matches found'); }); - it('updates hidden input with selected namespace', async () => { + it('emits `update-visibility` event to update the visibility radio options', async () => { wrapper = mountComponent(); - jest.runOnlyPendingTimers(); await wrapper.vm.$nextTick(); - wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + const spy = jest.spyOn(eventHub, '$emit'); + await clickDropdownItem(); + + const namespace = data.currentUser.groups.nodes[0]; + + expect(spy).toHaveBeenCalledWith('update-visibility', { + name: namespace.name, + visibility: namespace.visibility, + showPath: namespace.webUrl, + editPath: `${namespace.webUrl}/-/edit`, + }); + }); + + it('updates hidden input with selected namespace', async () => { + wrapper = mountComponent(); + jest.runOnlyPendingTimers(); await wrapper.vm.$nextTick(); + await clickDropdownItem(); + expect(findHiddenInput().attributes()).toMatchObject({ name: 'project[namespace_id]', value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js index 987a215eb4c..b4067f6a72b 100644 --- a/spec/frontend/projects/pipelines/charts/components/app_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js @@ -11,6 +11,7 @@ jest.mock('~/lib/utils/url_utility'); const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} }; const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} }; +const ProjectQualitySummaryStub = { name: 'ProjectQualitySummary', render: () => {} }; describe('ProjectsPipelinesChartsApp', () => { let wrapper; @@ -23,10 +24,12 @@ describe('ProjectsPipelinesChartsApp', () => { { provide: { shouldRenderDoraCharts: true, + shouldRenderQualitySummary: true, }, stubs: { DeploymentFrequencyCharts: DeploymentFrequencyChartsStub, LeadTimeCharts: LeadTimeChartsStub, + ProjectQualitySummary: ProjectQualitySummaryStub, }, }, mountOptions, @@ -44,6 +47,7 @@ describe('ProjectsPipelinesChartsApp', () => { const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub); const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub); const findPipelineCharts = () => wrapper.find(PipelineCharts); + const findProjectQualitySummary = () => wrapper.find(ProjectQualitySummaryStub); describe('when all charts are available', () => { beforeEach(() => { @@ -70,6 +74,10 @@ describe('ProjectsPipelinesChartsApp', () => { expect(findLeadTimeCharts().exists()).toBe(true); }); + it('renders the project quality summary', () => { + expect(findProjectQualitySummary().exists()).toBe(true); + }); + it('sets the tab and url when a tab is clicked', async () => { let chartsPath; setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts`); @@ -163,9 +171,11 @@ describe('ProjectsPipelinesChartsApp', () => { }); }); - describe('when the dora charts are not available', () => { + describe('when the dora charts are not available and project quality summary is not available', () => { beforeEach(() => { - createComponent({ provide: { shouldRenderDoraCharts: false } }); + createComponent({ + provide: { shouldRenderDoraCharts: false, shouldRenderQualitySummary: false }, + }); }); it('does not render tabs', () => { @@ -176,4 +186,14 @@ describe('ProjectsPipelinesChartsApp', () => { expect(findPipelineCharts().exists()).toBe(true); }); }); + + describe('when the project quality summary is not available', () => { + beforeEach(() => { + createComponent({ provide: { shouldRenderQualitySummary: false } }); + }); + + it('does not render the tab', () => { + expect(findProjectQualitySummary().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/projects/projects_filterable_list_spec.js b/spec/frontend/projects/projects_filterable_list_spec.js index d4dbf85b5ca..a41e8b7bc09 100644 --- a/spec/frontend/projects/projects_filterable_list_spec.js +++ b/spec/frontend/projects/projects_filterable_list_spec.js @@ -1,5 +1,4 @@ -// eslint-disable-next-line import/no-deprecated -import { getJSONFixture, setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture } from 'helpers/fixtures'; import ProjectsFilterableList from '~/projects/projects_filterable_list'; describe('ProjectsFilterableList', () => { @@ -15,8 +14,6 @@ describe('ProjectsFilterableList', () => {

    `); - // eslint-disable-next-line import/no-deprecated - getJSONFixture('static/projects.json'); form = document.querySelector('form#project-filter-form'); filter = document.querySelector('.js-projects-list-filter'); holder = document.querySelector('.js-projects-list-holder'); diff --git a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js new file mode 100644 index 00000000000..dbea94cbd53 --- /dev/null +++ b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js @@ -0,0 +1,98 @@ +import { GlTokenSelector, GlToken } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import TopicsTokenSelector from '~/projects/settings/topics/components/topics_token_selector.vue'; + +const mockTopics = [ + { id: 1, name: 'topic1', avatarUrl: 'avatar.com/topic1.png' }, + { id: 2, name: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' }, +]; + +describe('TopicsTokenSelector', () => { + let wrapper; + let div; + let input; + + const createComponent = (selected) => { + wrapper = mount(TopicsTokenSelector, { + attachTo: div, + propsData: { + selected, + }, + data() { + return { + topics: mockTopics, + }; + }, + mocks: { + $apollo: { + queries: { + topics: { loading: false }, + }, + }, + }, + }); + }; + + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + + const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]'); + + const setTokenSelectorInputValue = (value) => { + const tokenSelectorInput = findTokenSelectorInput(); + + tokenSelectorInput.element.value = value; + tokenSelectorInput.trigger('input'); + + return nextTick(); + }; + + const tokenSelectorTriggerEnter = (event) => { + const tokenSelectorInput = findTokenSelectorInput(); + tokenSelectorInput.trigger('keydown.enter', event); + }; + + beforeEach(() => { + div = document.createElement('div'); + input = document.createElement('input'); + input.setAttribute('type', 'text'); + input.id = 'project_topic_list_field'; + document.body.appendChild(div); + document.body.appendChild(input); + }); + + afterEach(() => { + wrapper.destroy(); + div.remove(); + input.remove(); + }); + + describe('when component is mounted', () => { + it('parses selected into tokens', async () => { + const selected = [ + { id: 11, name: 'topic1' }, + { id: 12, name: 'topic2' }, + { id: 13, name: 'topic3' }, + ]; + createComponent(selected); + await nextTick(); + + wrapper.findAllComponents(GlToken).wrappers.forEach((tokenWrapper, index) => { + expect(tokenWrapper.text()).toBe(selected[index].name); + }); + }); + }); + + describe('when enter key is pressed', () => { + it('does not submit the form if token selector text input has a value', async () => { + createComponent(); + + await setTokenSelectorInputValue('topic'); + + const event = { preventDefault: jest.fn() }; + tokenSelectorTriggerEnter(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/projects/settings_service_desk/components/mock_data.js b/spec/frontend/projects/settings_service_desk/components/mock_data.js new file mode 100644 index 00000000000..934778ff601 --- /dev/null +++ b/spec/frontend/projects/settings_service_desk/components/mock_data.js @@ -0,0 +1,8 @@ +export const TEMPLATES = [ + 'Project #1', + [ + { name: 'Bug', project_id: 1 }, + { name: 'Documentation', project_id: 1 }, + { name: 'Security release', project_id: 1 }, + ], +]; diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index 8acf2376860..62224612387 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -21,6 +21,7 @@ describe('ServiceDeskRoot', () => { outgoingName: 'GitLab Support Bot', projectKey: 'key', selectedTemplate: 'Bug', + selectedFileTemplateProjectId: 42, templates: ['Bug', 'Documentation'], }; @@ -52,6 +53,7 @@ describe('ServiceDeskRoot', () => { initialOutgoingName: provideData.outgoingName, initialProjectKey: provideData.projectKey, initialSelectedTemplate: provideData.selectedTemplate, + initialSelectedFileTemplateProjectId: provideData.selectedFileTemplateProjectId, isEnabled: provideData.initialIsEnabled, isTemplateSaving: false, templates: provideData.templates, diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index eacf858f22c..0fd3e7446da 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlFormSelect, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -13,7 +13,7 @@ describe('ServiceDeskSetting', () => { const findIncomingEmail = () => wrapper.findByTestId('incoming-email'); const findIncomingEmailLabel = () => wrapper.findByTestId('incoming-email-describer'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findTemplateDropdown = () => wrapper.find(GlFormSelect); + const findTemplateDropdown = () => wrapper.find(GlDropdown); const findToggle = () => wrapper.find(GlToggle); const createComponent = ({ props = {}, mountFunction = shallowMount } = {}) => @@ -128,6 +128,23 @@ describe('ServiceDeskSetting', () => { expect(input.exists()).toBe(true); expect(input.attributes('disabled')).toBeUndefined(); }); + + it('shows error when value contains uppercase or special chars', async () => { + wrapper = createComponent({ + props: { customEmailEnabled: true }, + mountFunction: mount, + }); + + const input = wrapper.findByTestId('project-suffix'); + + input.setValue('abc_A.'); + input.trigger('blur'); + + await wrapper.vm.$nextTick(); + + const errorText = wrapper.find('.text-danger'); + expect(errorText.exists()).toBe(true); + }); }); describe('customEmail is the same as incomingEmail', () => { @@ -144,63 +161,6 @@ describe('ServiceDeskSetting', () => { }); }); }); - - describe('templates dropdown', () => { - it('renders a dropdown to choose a template', () => { - wrapper = createComponent(); - - expect(findTemplateDropdown().exists()).toBe(true); - }); - - it('renders a dropdown with a default value of ""', () => { - wrapper = createComponent({ mountFunction: mount }); - - expect(findTemplateDropdown().element.value).toEqual(''); - }); - - it('renders a dropdown with a value of "Bug" when it is the initial value', () => { - const templates = ['Bug', 'Documentation', 'Security release']; - - wrapper = createComponent({ - props: { initialSelectedTemplate: 'Bug', templates }, - mountFunction: mount, - }); - - expect(findTemplateDropdown().element.value).toEqual('Bug'); - }); - - it('renders a dropdown with no options when the project has no templates', () => { - wrapper = createComponent({ - props: { templates: [] }, - mountFunction: mount, - }); - - // The dropdown by default has one empty option - expect(findTemplateDropdown().element.children).toHaveLength(1); - }); - - it('renders a dropdown with options when the project has templates', () => { - const templates = ['Bug', 'Documentation', 'Security release']; - - wrapper = createComponent({ - props: { templates }, - mountFunction: mount, - }); - - // An empty-named template is prepended so the user can select no template - const expectedTemplates = [''].concat(templates); - - const dropdown = findTemplateDropdown(); - const dropdownList = Array.from(dropdown.element.children).map( - (option) => option.innerText, - ); - - expect(dropdown.element.children).toHaveLength(expectedTemplates.length); - expect(dropdownList.includes('Bug')).toEqual(true); - expect(dropdownList.includes('Documentation')).toEqual(true); - expect(dropdownList.includes('Security release')).toEqual(true); - }); - }); }); describe('save button', () => { @@ -214,6 +174,7 @@ describe('ServiceDeskSetting', () => { wrapper = createComponent({ props: { initialSelectedTemplate: 'Bug', + initialSelectedFileTemplateProjectId: 42, initialOutgoingName: 'GitLab Support Bot', initialProjectKey: 'key', }, @@ -225,6 +186,7 @@ describe('ServiceDeskSetting', () => { const payload = { selectedTemplate: 'Bug', + fileTemplateProjectId: 42, outgoingName: 'GitLab Support Bot', projectKey: 'key', }; diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js new file mode 100644 index 00000000000..cdb355f5a9b --- /dev/null +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js @@ -0,0 +1,80 @@ +import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import ServiceDeskTemplateDropdown from '~/projects/settings_service_desk/components/service_desk_setting.vue'; +import { TEMPLATES } from './mock_data'; + +describe('ServiceDeskTemplateDropdown', () => { + let wrapper; + + const findTemplateDropdown = () => wrapper.find(GlDropdown); + + const createComponent = ({ props = {} } = {}) => + extendedWrapper( + mount(ServiceDeskTemplateDropdown, { + propsData: { + isEnabled: true, + ...props, + }, + }), + ); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('templates dropdown', () => { + it('renders a dropdown to choose a template', () => { + wrapper = createComponent(); + + expect(findTemplateDropdown().exists()).toBe(true); + }); + + it('renders a dropdown with a default value of "Choose a template"', () => { + wrapper = createComponent(); + + expect(findTemplateDropdown().props('text')).toEqual('Choose a template'); + }); + + it('renders a dropdown with a value of "Bug" when it is the initial value', () => { + const templates = TEMPLATES; + + wrapper = createComponent({ + props: { initialSelectedTemplate: 'Bug', initialSelectedTemplateProjectId: 1, templates }, + }); + + expect(findTemplateDropdown().props('text')).toEqual('Bug'); + }); + + it('renders a dropdown with header items', () => { + wrapper = createComponent({ + props: { templates: TEMPLATES }, + }); + + const headerItems = wrapper.findAll(GlDropdownSectionHeader); + + expect(headerItems).toHaveLength(1); + expect(headerItems.at(0).text()).toBe(TEMPLATES[0]); + }); + + it('renders a dropdown with options when the project has templates', () => { + const templates = TEMPLATES; + + wrapper = createComponent({ + props: { templates }, + }); + + const expectedTemplates = templates[1]; + + const items = wrapper.findAll(GlDropdownItem); + const dropdownList = expectedTemplates.map((_, index) => items.at(index).text()); + + expect(items).toHaveLength(expectedTemplates.length); + expect(dropdownList.includes('Bug')).toEqual(true); + expect(dropdownList.includes('Documentation')).toEqual(true); + expect(dropdownList.includes('Security release')).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/projects/storage_counter/components/storage_table_spec.js b/spec/frontend/projects/storage_counter/components/storage_table_spec.js index 14298318fff..c9e56d8f033 100644 --- a/spec/frontend/projects/storage_counter/components/storage_table_spec.js +++ b/spec/frontend/projects/storage_counter/components/storage_table_spec.js @@ -1,4 +1,4 @@ -import { GlTable } from '@gitlab/ui'; +import { GlTableLite } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import StorageTable from '~/projects/storage_counter/components/storage_table.vue'; @@ -22,7 +22,7 @@ describe('StorageTable', () => { ); }; - const findTable = () => wrapper.findComponent(GlTable); + const findTable = () => wrapper.findComponent(GlTableLite); beforeEach(() => { createComponent(); @@ -37,6 +37,7 @@ describe('StorageTable', () => { ({ storageType: { id, name, description } }) => { expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name); expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description); + expect(wrapper.findByTestId(`${id}-icon`).props('name')).toBe(id); expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe( defaultProvideValues.helpLinks[id.replace(`Size`, `HelpPagePath`)] .replace(`Size`, ``) diff --git a/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js b/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js new file mode 100644 index 00000000000..01efd6f14bd --- /dev/null +++ b/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js @@ -0,0 +1,41 @@ +import { mount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import StorageTypeIcon from '~/projects/storage_counter/components/storage_type_icon.vue'; + +describe('StorageTypeIcon', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(StorageTypeIcon, { + propsData: { + ...props, + }, + }); + }; + + const findGlIcon = () => wrapper.findComponent(GlIcon); + + describe('rendering icon', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + expected | provided + ${'doc-image'} | ${'lfsObjectsSize'} + ${'snippet'} | ${'snippetsSize'} + ${'infrastructure-registry'} | ${'repositorySize'} + ${'package'} | ${'packagesSize'} + ${'upload'} | ${'uploadsSize'} + ${'disk'} | ${'wikiSize'} + ${'disk'} | ${'anything-else'} + `( + 'renders icon with name of $expected when name prop is $provided', + ({ expected, provided }) => { + createComponent({ name: provided }); + + expect(findGlIcon().props('name')).toBe(expected); + }, + ); + }); +}); diff --git a/spec/frontend/projects/storage_counter/mock_data.js b/spec/frontend/projects/storage_counter/mock_data.js index b9fa68b3ec7..6b3e23ac386 100644 --- a/spec/frontend/projects/storage_counter/mock_data.js +++ b/spec/frontend/projects/storage_counter/mock_data.js @@ -1,23 +1,6 @@ -export const mockGetProjectStorageCountGraphQLResponse = { - data: { - project: { - id: 'gid://gitlab/Project/20', - statistics: { - buildArtifactsSize: 400000.0, - pipelineArtifactsSize: 25000.0, - lfsObjectsSize: 4800000.0, - packagesSize: 3800000.0, - repositorySize: 3900000.0, - snippetsSize: 1200000.0, - storageSize: 15300000.0, - uploadsSize: 900000.0, - wikiSize: 300000.0, - __typename: 'ProjectStatistics', - }, - __typename: 'Project', - }, - }, -}; +import mockGetProjectStorageCountGraphQLResponse from 'test_fixtures/graphql/projects/storage_counter/project_storage.query.graphql.json'; + +export { mockGetProjectStorageCountGraphQLResponse }; export const mockEmptyResponse = { data: { project: null } }; @@ -37,7 +20,7 @@ export const defaultProvideValues = { export const projectData = { storage: { - totalUsage: '14.6 MiB', + totalUsage: '13.8 MiB', storageTypes: [ { storageType: { @@ -45,7 +28,7 @@ export const projectData = { name: 'Artifacts', description: 'Pipeline artifacts and job artifacts, created with CI/CD.', warningMessage: - 'There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.', + 'Because of a known issue, the artifact total for some projects may be incorrect. For more details, read %{warningLinkStart}the epic%{warningLinkEnd}.', helpPath: '/build-artifacts', }, value: 400000, @@ -53,7 +36,7 @@ export const projectData = { { storageType: { id: 'lfsObjectsSize', - name: 'LFS Storage', + name: 'LFS storage', description: 'Audio samples, videos, datasets, and graphics.', helpPath: '/lsf-objects', }, @@ -72,7 +55,7 @@ export const projectData = { storageType: { id: 'repositorySize', name: 'Repository', - description: 'Git repository, managed by the Gitaly service.', + description: 'Git repository.', helpPath: '/repository', }, value: 3900000, @@ -84,7 +67,7 @@ export const projectData = { description: 'Shared bits of code and text.', helpPath: '/snippets', }, - value: 1200000, + value: 0, }, { storageType: { diff --git a/spec/frontend/projects/storage_counter/utils_spec.js b/spec/frontend/projects/storage_counter/utils_spec.js index 57c755266a0..fb91975a3cf 100644 --- a/spec/frontend/projects/storage_counter/utils_spec.js +++ b/spec/frontend/projects/storage_counter/utils_spec.js @@ -14,4 +14,21 @@ describe('parseGetProjectStorageResults', () => { ), ).toMatchObject(projectData); }); + + it('includes storage type with size of 0 in returned value', () => { + const mockedResponse = mockGetProjectStorageCountGraphQLResponse.data; + // ensuring a specific storage type item has size of 0 + mockedResponse.project.statistics.repositorySize = 0; + + const response = parseGetProjectStorageResults(mockedResponse, defaultProvideValues.helpLinks); + + expect(response.storage.storageTypes).toEqual( + expect.arrayContaining([ + { + storageType: expect.any(Object), + value: 0, + }, + ]), + ); + }); }); diff --git a/spec/frontend/projects/upload_file_experiment_tracking_spec.js b/spec/frontend/projects/upload_file_experiment_tracking_spec.js deleted file mode 100644 index 6817529e07e..00000000000 --- a/spec/frontend/projects/upload_file_experiment_tracking_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import ExperimentTracking from '~/experimentation/experiment_tracking'; -import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking'; - -jest.mock('~/experimentation/experiment_tracking'); - -const eventName = 'click_upload_modal_form_submit'; -const fixture = `
    `; - -beforeEach(() => { - document.body.innerHTML = fixture; -}); - -afterEach(() => { - document.body.innerHTML = ''; -}); - -describe('trackFileUploadEvent', () => { - it('initializes ExperimentTracking with the correct tracking event', () => { - trackFileUploadEvent(eventName); - - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(eventName); - }); - - it('calls ExperimentTracking with the correct arguments', () => { - trackFileUploadEvent(eventName); - - expect(ExperimentTracking).toHaveBeenCalledWith('empty_repo_upload', { - label: 'blob-upload-modal', - property: 'empty', - }); - }); - - it('calls ExperimentTracking with the correct arguments when the project is not empty', () => { - document.querySelector('.empty-project').remove(); - - trackFileUploadEvent(eventName); - - expect(ExperimentTracking).toHaveBeenCalledWith('empty_repo_upload', { - label: 'blob-upload-modal', - property: 'nonempty', - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap deleted file mode 100644 index f80e2ce6ecc..00000000000 --- a/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ /dev/null @@ -1,72 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` - -`; - -exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` - -`; diff --git a/spec/frontend/registry/explorer/components/delete_button_spec.js b/spec/frontend/registry/explorer/components/delete_button_spec.js deleted file mode 100644 index 4597c42add9..00000000000 --- a/spec/frontend/registry/explorer/components/delete_button_spec.js +++ /dev/null @@ -1,73 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import component from '~/registry/explorer/components/delete_button.vue'; - -describe('delete_button', () => { - let wrapper; - - const defaultProps = { - title: 'Foo title', - tooltipTitle: 'Bar tooltipTitle', - }; - - const findButton = () => wrapper.find(GlButton); - - const mountComponent = (props) => { - wrapper = shallowMount(component, { - propsData: { - ...defaultProps, - ...props, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('tooltip', () => { - it('the title is controlled by tooltipTitle prop', () => { - mountComponent(); - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip).toBeDefined(); - expect(tooltip.value.title).toBe(defaultProps.tooltipTitle); - }); - - it('is disabled when tooltipTitle is disabled', () => { - mountComponent({ tooltipDisabled: true }); - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip.value.disabled).toBe(true); - }); - - describe('button', () => { - it('exists', () => { - mountComponent(); - expect(findButton().exists()).toBe(true); - }); - - it('has the correct props/attributes bound', () => { - mountComponent({ disabled: true }); - expect(findButton().attributes()).toMatchObject({ - 'aria-label': 'Foo title', - icon: 'remove', - title: 'Foo title', - variant: 'danger', - disabled: 'true', - category: 'secondary', - }); - }); - - it('emits a delete event', () => { - mountComponent(); - expect(wrapper.emitted('delete')).toEqual(undefined); - findButton().vm.$emit('click'); - expect(wrapper.emitted('delete')).toEqual([[]]); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/delete_image_spec.js b/spec/frontend/registry/explorer/components/delete_image_spec.js deleted file mode 100644 index 9a0d070e42b..00000000000 --- a/spec/frontend/registry/explorer/components/delete_image_spec.js +++ /dev/null @@ -1,152 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import component from '~/registry/explorer/components/delete_image.vue'; -import { GRAPHQL_PAGE_SIZE } from '~/registry/explorer/constants/index'; -import deleteContainerRepositoryMutation from '~/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql'; -import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; - -describe('Delete Image', () => { - let wrapper; - const id = '1'; - const storeMock = { - readQuery: jest.fn().mockReturnValue({ - containerRepository: { - status: 'foo', - }, - }), - writeQuery: jest.fn(), - }; - - const updatePayload = { - data: { - destroyContainerRepository: { - containerRepository: { - status: 'baz', - }, - }, - }, - }; - - const findButton = () => wrapper.find('button'); - - const mountComponent = ({ - propsData = { id }, - mutate = jest.fn().mockResolvedValue({}), - } = {}) => { - wrapper = shallowMount(component, { - propsData, - mocks: { - $apollo: { - mutate, - }, - }, - scopedSlots: { - default: '', - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('executes apollo mutate on doDelete', () => { - const mutate = jest.fn().mockResolvedValue({}); - mountComponent({ mutate }); - - wrapper.vm.doDelete(); - - expect(mutate).toHaveBeenCalledWith({ - mutation: deleteContainerRepositoryMutation, - variables: { - id, - }, - update: undefined, - }); - }); - - it('on success emits the correct events', async () => { - const mutate = jest.fn().mockResolvedValue({}); - mountComponent({ mutate }); - - wrapper.vm.doDelete(); - - await waitForPromises(); - - expect(wrapper.emitted('start')).toEqual([[]]); - expect(wrapper.emitted('success')).toEqual([[]]); - expect(wrapper.emitted('end')).toEqual([[]]); - }); - - it('when a payload contains an error emits an error event', async () => { - const mutate = jest - .fn() - .mockResolvedValue({ data: { destroyContainerRepository: { errors: ['foo'] } } }); - - mountComponent({ mutate }); - wrapper.vm.doDelete(); - - await waitForPromises(); - - expect(wrapper.emitted('error')).toEqual([[['foo']]]); - }); - - it('when the api call errors emits an error event', async () => { - const mutate = jest.fn().mockRejectedValue('error'); - - mountComponent({ mutate }); - wrapper.vm.doDelete(); - - await waitForPromises(); - - expect(wrapper.emitted('error')).toEqual([[['error']]]); - }); - - it('uses the update function, when the prop is set to true', () => { - const mutate = jest.fn().mockResolvedValue({}); - - mountComponent({ mutate, propsData: { id, useUpdateFn: true } }); - wrapper.vm.doDelete(); - - expect(mutate).toHaveBeenCalledWith({ - mutation: deleteContainerRepositoryMutation, - variables: { - id, - }, - update: wrapper.vm.updateImageStatus, - }); - }); - - it('updateImage status reads and write to the cache', () => { - mountComponent(); - - const variables = { - id, - first: GRAPHQL_PAGE_SIZE, - }; - - wrapper.vm.updateImageStatus(storeMock, updatePayload); - - expect(storeMock.readQuery).toHaveBeenCalledWith({ - query: getContainerRepositoryDetailsQuery, - variables, - }); - expect(storeMock.writeQuery).toHaveBeenCalledWith({ - query: getContainerRepositoryDetailsQuery, - variables, - data: { - containerRepository: { - status: updatePayload.data.destroyContainerRepository.containerRepository.status, - }, - }, - }); - }); - - it('binds the doDelete function to the default scoped slot', () => { - const mutate = jest.fn().mockResolvedValue({}); - mountComponent({ mutate }); - findButton().trigger('click'); - expect(mutate).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap b/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap deleted file mode 100644 index 5f191ef5561..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap +++ /dev/null @@ -1,61 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TagsLoader component has the correct markup 1`] = ` -
    -
    - - - - - - - - - - - - - -
    -
    -`; diff --git a/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js deleted file mode 100644 index c2a2a4e06ea..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js +++ /dev/null @@ -1,116 +0,0 @@ -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/registry/explorer/components/details_page/delete_alert.vue'; -import { - DELETE_TAG_SUCCESS_MESSAGE, - DELETE_TAG_ERROR_MESSAGE, - DELETE_TAGS_SUCCESS_MESSAGE, - DELETE_TAGS_ERROR_MESSAGE, - ADMIN_GARBAGE_COLLECTION_TIP, -} from '~/registry/explorer/constants'; - -describe('Delete alert', () => { - let wrapper; - - const findAlert = () => wrapper.find(GlAlert); - const findLink = () => wrapper.find(GlLink); - - const mountComponent = (propsData) => { - wrapper = shallowMount(component, { stubs: { GlSprintf }, propsData }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when deleteAlertType is null', () => { - it('does not show the alert', () => { - mountComponent(); - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('when deleteAlertType is not null', () => { - describe('success states', () => { - describe.each` - deleteAlertType | message - ${'success_tag'} | ${DELETE_TAG_SUCCESS_MESSAGE} - ${'success_tags'} | ${DELETE_TAGS_SUCCESS_MESSAGE} - `('when deleteAlertType is $deleteAlertType', ({ deleteAlertType, message }) => { - it('alert exists', () => { - mountComponent({ deleteAlertType }); - expect(findAlert().exists()).toBe(true); - }); - - describe('when the user is an admin', () => { - beforeEach(() => { - mountComponent({ - deleteAlertType, - isAdmin: true, - garbageCollectionHelpPagePath: 'foo', - }); - }); - - it(`alert title is ${message}`, () => { - expect(findAlert().attributes('title')).toBe(message); - }); - - it('alert body contains admin tip', () => { - expect(findAlert().text()).toMatchInterpolatedText(ADMIN_GARBAGE_COLLECTION_TIP); - }); - - it('alert body contains link', () => { - const alertLink = findLink(); - expect(alertLink.exists()).toBe(true); - expect(alertLink.attributes('href')).toBe('foo'); - }); - }); - - describe('when the user is not an admin', () => { - it('alert exist and text is appropriate', () => { - mountComponent({ deleteAlertType }); - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(message); - }); - }); - }); - }); - describe('error states', () => { - describe.each` - deleteAlertType | message - ${'danger_tag'} | ${DELETE_TAG_ERROR_MESSAGE} - ${'danger_tags'} | ${DELETE_TAGS_ERROR_MESSAGE} - `('when deleteAlertType is $deleteAlertType', ({ deleteAlertType, message }) => { - it('alert exists', () => { - mountComponent({ deleteAlertType }); - expect(findAlert().exists()).toBe(true); - }); - - describe('when the user is an admin', () => { - it('alert exist and text is appropriate', () => { - mountComponent({ deleteAlertType }); - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(message); - }); - }); - - describe('when the user is not an admin', () => { - it('alert exist and text is appropriate', () => { - mountComponent({ deleteAlertType }); - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(message); - }); - }); - }); - }); - - describe('dismissing alert', () => { - it('GlAlert dismiss event triggers a change event', () => { - mountComponent({ deleteAlertType: 'success_tags' }); - findAlert().vm.$emit('dismiss'); - expect(wrapper.emitted('change')).toEqual([[null]]); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js deleted file mode 100644 index d2fe5af3a94..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js +++ /dev/null @@ -1,152 +0,0 @@ -import { GlSprintf, GlFormInput } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import component from '~/registry/explorer/components/details_page/delete_modal.vue'; -import { - REMOVE_TAG_CONFIRMATION_TEXT, - REMOVE_TAGS_CONFIRMATION_TEXT, - DELETE_IMAGE_CONFIRMATION_TITLE, - DELETE_IMAGE_CONFIRMATION_TEXT, -} from '~/registry/explorer/constants'; -import { GlModal } from '../../stubs'; - -describe('Delete Modal', () => { - let wrapper; - - const findModal = () => wrapper.findComponent(GlModal); - const findDescription = () => wrapper.find('[data-testid="description"]'); - const findInputComponent = () => wrapper.findComponent(GlFormInput); - - const mountComponent = (propsData) => { - wrapper = shallowMount(component, { - propsData, - stubs: { - GlSprintf, - GlModal, - }, - }); - }; - - const expectPrimaryActionStatus = (disabled = true) => - expect(findModal().props('actionPrimary')).toMatchObject( - expect.objectContaining({ - attributes: [{ variant: 'danger' }, { disabled }], - }), - ); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('contains a GlModal', () => { - mountComponent(); - expect(findModal().exists()).toBe(true); - }); - - describe('events', () => { - it.each` - glEvent | localEvent - ${'primary'} | ${'confirmDelete'} - ${'cancel'} | ${'cancelDelete'} - `('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => { - mountComponent(); - findModal().vm.$emit(glEvent); - expect(wrapper.emitted(localEvent)).toEqual([[]]); - }); - }); - - describe('methods', () => { - it('show calls gl-modal show', () => { - mountComponent(); - wrapper.vm.show(); - expect(GlModal.methods.show).toHaveBeenCalled(); - }); - }); - - describe('when we are deleting images', () => { - it('has the correct title', () => { - mountComponent({ deleteImage: true }); - - expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TITLE); - }); - - it('has the correct description', () => { - mountComponent({ deleteImage: true }); - - expect(wrapper.text()).toContain( - DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(), - ); - }); - - describe('delete button', () => { - const itemsToBeDeleted = [{ project: { path: 'foo' } }]; - - it('is disabled by default', () => { - mountComponent({ deleteImage: true }); - - expectPrimaryActionStatus(); - }); - - it('if the user types something different from the project path is disabled', async () => { - mountComponent({ deleteImage: true, itemsToBeDeleted }); - - findInputComponent().vm.$emit('input', 'bar'); - - await nextTick(); - - expectPrimaryActionStatus(); - }); - - it('if the user types the project path it is enabled', async () => { - mountComponent({ deleteImage: true, itemsToBeDeleted }); - - findInputComponent().vm.$emit('input', 'foo'); - - await nextTick(); - - expectPrimaryActionStatus(false); - }); - }); - }); - - describe('when we are deleting tags', () => { - it('delete button is enabled', () => { - mountComponent(); - - expectPrimaryActionStatus(false); - }); - - describe('itemsToBeDeleted contains one element', () => { - beforeEach(() => { - mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] }); - }); - - it(`has the correct description`, () => { - expect(findDescription().text()).toBe( - REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'), - ); - }); - - it('has the correct title', () => { - expect(wrapper.text()).toContain('Remove tag'); - }); - }); - - describe('itemsToBeDeleted contains more than element', () => { - beforeEach(() => { - mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] }); - }); - - it(`has the correct description`, () => { - expect(findDescription().text()).toBe( - REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'), - ); - }); - - it('has the correct title', () => { - expect(wrapper.text()).toContain('Remove tags'); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js deleted file mode 100644 index acff5c21940..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ /dev/null @@ -1,304 +0,0 @@ -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import { useFakeDate } from 'helpers/fake_date'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import waitForPromises from 'helpers/wait_for_promises'; -import { GlDropdown } from 'jest/registry/explorer/stubs'; -import component from '~/registry/explorer/components/details_page/details_header.vue'; -import { - UNSCHEDULED_STATUS, - SCHEDULED_STATUS, - ONGOING_STATUS, - UNFINISHED_STATUS, - CLEANUP_DISABLED_TEXT, - CLEANUP_DISABLED_TOOLTIP, - CLEANUP_SCHEDULED_TOOLTIP, - CLEANUP_ONGOING_TOOLTIP, - CLEANUP_UNFINISHED_TOOLTIP, - ROOT_IMAGE_TEXT, - ROOT_IMAGE_TOOLTIP, -} from '~/registry/explorer/constants'; -import getContainerRepositoryTagCountQuery from '~/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql'; -import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import { imageTagsCountMock } from '../../mock_data'; - -describe('Details Header', () => { - let wrapper; - let apolloProvider; - let localVue; - - const defaultImage = { - name: 'foo', - updatedAt: '2020-11-03T13:29:21Z', - canDelete: true, - project: { - visibility: 'public', - containerExpirationPolicy: { - enabled: false, - }, - }, - }; - - // set the date to Dec 4, 2020 - useFakeDate(2020, 11, 4); - const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); - - const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility'); - const findTitle = () => findByTestId('title'); - const findTagsCount = () => findByTestId('tags-count'); - const findCleanup = () => findByTestId('cleanup'); - const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); - const findInfoIcon = () => wrapper.findComponent(GlIcon); - - const waitForMetadataItems = async () => { - // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); - }; - - const mountComponent = ({ - propsData = { image: defaultImage }, - resolver = jest.fn().mockResolvedValue(imageTagsCountMock()), - $apollo = undefined, - } = {}) => { - const mocks = {}; - - if ($apollo) { - mocks.$apollo = $apollo; - } else { - localVue = createLocalVue(); - localVue.use(VueApollo); - - const requestHandlers = [[getContainerRepositoryTagCountQuery, resolver]]; - apolloProvider = createMockApollo(requestHandlers); - } - - wrapper = shallowMount(component, { - localVue, - apolloProvider, - propsData, - directives: { - GlTooltip: createMockDirective(), - }, - mocks, - stubs: { - TitleArea, - GlDropdown, - GlDropdownItem, - }, - }); - }; - - afterEach(() => { - // if we want to mix createMockApollo and manual mocks we need to reset everything - wrapper.destroy(); - apolloProvider = undefined; - localVue = undefined; - wrapper = null; - }); - - describe('image name', () => { - describe('missing image name', () => { - beforeEach(() => { - mountComponent({ propsData: { image: { ...defaultImage, name: '' } } }); - - return waitForPromises(); - }); - - it('root image ', () => { - expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); - }); - - it('has an icon', () => { - expect(findInfoIcon().exists()).toBe(true); - expect(findInfoIcon().props('name')).toBe('information-o'); - }); - - it('has a tooltip', () => { - const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip'); - expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP); - }); - }); - - describe('with image name present', () => { - beforeEach(() => { - mountComponent(); - - return waitForPromises(); - }); - - it('shows image.name ', () => { - expect(findTitle().text()).toContain('foo'); - }); - - it('has no icon', () => { - expect(findInfoIcon().exists()).toBe(false); - }); - }); - }); - - describe('delete button', () => { - it('exists', () => { - mountComponent(); - - expect(findDeleteButton().exists()).toBe(true); - }); - - it('has the correct text', () => { - mountComponent(); - - expect(findDeleteButton().text()).toBe('Delete image repository'); - }); - - it('has the correct props', () => { - mountComponent(); - - expect(findDeleteButton().attributes()).toMatchObject( - expect.objectContaining({ - variant: 'danger', - }), - ); - }); - - it('emits the correct event', () => { - mountComponent(); - - findDeleteButton().vm.$emit('click'); - - expect(wrapper.emitted('delete')).toEqual([[]]); - }); - - it.each` - canDelete | disabled | isDisabled - ${true} | ${false} | ${undefined} - ${true} | ${true} | ${'true'} - ${false} | ${false} | ${'true'} - ${false} | ${true} | ${'true'} - `( - 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', - ({ canDelete, disabled, isDisabled }) => { - mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); - - expect(findDeleteButton().attributes('disabled')).toBe(isDisabled); - }, - ); - }); - - describe('metadata items', () => { - describe('tags count', () => { - it('displays "-- tags" while loading', async () => { - // here we are forced to mock apollo because `waitForMetadataItems` waits - // for two ticks, de facto allowing the promise to resolve, so there is - // no way to catch the component as both rendered and in loading state - mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } }); - - await waitForMetadataItems(); - - expect(findTagsCount().props('text')).toBe('-- tags'); - }); - - it('when there is more than one tag has the correct text', async () => { - mountComponent(); - - await waitForPromises(); - await waitForMetadataItems(); - - expect(findTagsCount().props('text')).toBe('13 tags'); - }); - - it('when there is one tag has the correct text', async () => { - mountComponent({ - resolver: jest.fn().mockResolvedValue(imageTagsCountMock({ tagsCount: 1 })), - }); - - await waitForPromises(); - await waitForMetadataItems(); - - expect(findTagsCount().props('text')).toBe('1 tag'); - }); - - it('has the correct icon', async () => { - mountComponent(); - await waitForMetadataItems(); - - expect(findTagsCount().props('icon')).toBe('tag'); - }); - }); - - describe('cleanup metadata item', () => { - it('has the correct icon', async () => { - mountComponent(); - await waitForMetadataItems(); - - expect(findCleanup().props('icon')).toBe('expire'); - }); - - it('when the expiration policy is disabled', async () => { - mountComponent(); - await waitForMetadataItems(); - - expect(findCleanup().props()).toMatchObject({ - text: CLEANUP_DISABLED_TEXT, - textTooltip: CLEANUP_DISABLED_TOOLTIP, - }); - }); - - it.each` - status | text | tooltip - ${UNSCHEDULED_STATUS} | ${'Cleanup will run in 1 month'} | ${''} - ${SCHEDULED_STATUS} | ${'Cleanup pending'} | ${CLEANUP_SCHEDULED_TOOLTIP} - ${ONGOING_STATUS} | ${'Cleanup in progress'} | ${CLEANUP_ONGOING_TOOLTIP} - ${UNFINISHED_STATUS} | ${'Cleanup incomplete'} | ${CLEANUP_UNFINISHED_TOOLTIP} - `( - 'when the status is $status the text is $text and the tooltip is $tooltip', - async ({ status, text, tooltip }) => { - mountComponent({ - propsData: { - image: { - ...defaultImage, - expirationPolicyCleanupStatus: status, - project: { - containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, - }, - }, - }, - }); - await waitForMetadataItems(); - - expect(findCleanup().props()).toMatchObject({ - text, - textTooltip: tooltip, - }); - }, - ); - }); - - describe('visibility and updated at ', () => { - it('has last updated text', async () => { - mountComponent(); - await waitForMetadataItems(); - - expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago'); - }); - - describe('visibility icon', () => { - it('shows an eye when the project is public', async () => { - mountComponent(); - await waitForMetadataItems(); - - expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); - }); - it('shows an eye slashed when the project is not public', async () => { - mountComponent({ - propsData: { image: { ...defaultImage, project: { visibility: 'private' } } }, - }); - await waitForMetadataItems(); - - expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); - }); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/empty_state_spec.js b/spec/frontend/registry/explorer/components/details_page/empty_state_spec.js deleted file mode 100644 index 14b15945631..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/empty_state_spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/registry/explorer/components/details_page/empty_state.vue'; -import { - NO_TAGS_TITLE, - NO_TAGS_MESSAGE, - MISSING_OR_DELETED_IMAGE_TITLE, - MISSING_OR_DELETED_IMAGE_MESSAGE, -} from '~/registry/explorer/constants'; - -describe('EmptyTagsState component', () => { - let wrapper; - - const findEmptyState = () => wrapper.find(GlEmptyState); - - const mountComponent = (propsData) => { - wrapper = shallowMount(component, { - stubs: { - GlEmptyState, - }, - propsData, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('contains gl-empty-state', () => { - mountComponent(); - expect(findEmptyState().exists()).toBe(true); - }); - - it.each` - isEmptyImage | title | description - ${false} | ${NO_TAGS_TITLE} | ${NO_TAGS_MESSAGE} - ${true} | ${MISSING_OR_DELETED_IMAGE_TITLE} | ${MISSING_OR_DELETED_IMAGE_MESSAGE} - `( - 'when isEmptyImage is $isEmptyImage has the correct props', - ({ isEmptyImage, title, description }) => { - mountComponent({ - noContainersImage: 'foo', - isEmptyImage, - }); - - expect(findEmptyState().props()).toMatchObject({ - title, - description, - svgPath: 'foo', - }); - }, - ); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js deleted file mode 100644 index af8a23e412c..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js +++ /dev/null @@ -1,71 +0,0 @@ -import { GlAlert, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/registry/explorer/components/details_page/partial_cleanup_alert.vue'; -import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '~/registry/explorer/constants'; - -describe('Partial Cleanup alert', () => { - let wrapper; - - const findAlert = () => wrapper.find(GlAlert); - const findRunLink = () => wrapper.find('[data-testid="run-link"'); - const findHelpLink = () => wrapper.find('[data-testid="help-link"'); - - const mountComponent = () => { - wrapper = shallowMount(component, { - stubs: { GlSprintf }, - propsData: { - runCleanupPoliciesHelpPagePath: 'foo', - cleanupPoliciesHelpPagePath: 'bar', - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it(`gl-alert has the correct properties`, () => { - mountComponent(); - - expect(findAlert().props()).toMatchObject({ - title: DELETE_ALERT_TITLE, - variant: 'warning', - }); - }); - - it('has the right text', () => { - mountComponent(); - - expect(wrapper.text()).toMatchInterpolatedText(DELETE_ALERT_LINK_TEXT); - }); - - it('contains run link', () => { - mountComponent(); - - const link = findRunLink(); - expect(link.exists()).toBe(true); - expect(link.attributes()).toMatchObject({ - href: 'foo', - target: '_blank', - }); - }); - - it('contains help link', () => { - mountComponent(); - - const link = findHelpLink(); - expect(link.exists()).toBe(true); - expect(link.attributes()).toMatchObject({ - href: 'bar', - target: '_blank', - }); - }); - - it('GlAlert dismiss event triggers a dismiss event', () => { - mountComponent(); - - findAlert().vm.$emit('dismiss'); - expect(wrapper.emitted('dismiss')).toEqual([[]]); - }); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/status_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/status_alert_spec.js deleted file mode 100644 index b079883cefd..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/status_alert_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/registry/explorer/components/details_page/status_alert.vue'; -import { - DELETE_SCHEDULED, - DELETE_FAILED, - PACKAGE_DELETE_HELP_PAGE_PATH, - SCHEDULED_FOR_DELETION_STATUS_TITLE, - SCHEDULED_FOR_DELETION_STATUS_MESSAGE, - FAILED_DELETION_STATUS_TITLE, - FAILED_DELETION_STATUS_MESSAGE, -} from '~/registry/explorer/constants'; - -describe('Status Alert', () => { - let wrapper; - - const findLink = () => wrapper.find(GlLink); - const findAlert = () => wrapper.find(GlAlert); - const findMessage = () => wrapper.find('[data-testid="message"]'); - - const mountComponent = (propsData) => { - wrapper = shallowMount(component, { - propsData, - stubs: { - GlSprintf, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it.each` - status | title | variant | message | link - ${DELETE_SCHEDULED} | ${SCHEDULED_FOR_DELETION_STATUS_TITLE} | ${'info'} | ${SCHEDULED_FOR_DELETION_STATUS_MESSAGE} | ${PACKAGE_DELETE_HELP_PAGE_PATH} - ${DELETE_FAILED} | ${FAILED_DELETION_STATUS_TITLE} | ${'warning'} | ${FAILED_DELETION_STATUS_MESSAGE} | ${''} - `( - `when the status is $status, title is $title, variant is $variant, message is $message and the link is $link`, - ({ status, title, variant, message, link }) => { - mountComponent({ status }); - - expect(findMessage().text()).toMatchInterpolatedText(message); - expect(findAlert().props()).toMatchObject({ - title, - variant, - }); - if (link) { - expect(findLink().attributes()).toMatchObject({ - target: '_blank', - href: link, - }); - } - }, - ); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js deleted file mode 100644 index a5da37a2786..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js +++ /dev/null @@ -1,382 +0,0 @@ -import { GlFormCheckbox, GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; - -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - -import component from '~/registry/explorer/components/details_page/tags_list_row.vue'; -import { - REMOVE_TAG_BUTTON_TITLE, - MISSING_MANIFEST_WARNING_TOOLTIP, - NOT_AVAILABLE_TEXT, - NOT_AVAILABLE_SIZE, -} from '~/registry/explorer/constants/index'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; - -import { tagsMock } from '../../mock_data'; -import { ListItem } from '../../stubs'; - -describe('tags list row', () => { - let wrapper; - const [tag] = [...tagsMock]; - - const defaultProps = { tag, isMobile: false, index: 0 }; - - const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); - const findName = () => wrapper.find('[data-testid="name"]'); - const findSize = () => wrapper.find('[data-testid="size"]'); - const findTime = () => wrapper.find('[data-testid="time"]'); - const findShortRevision = () => wrapper.find('[data-testid="digest"]'); - const findClipboardButton = () => wrapper.findComponent(ClipboardButton); - const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); - const findDetailsRows = () => wrapper.findAll(DetailsRow); - const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]'); - const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); - const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); - const findWarningIcon = () => wrapper.findComponent(GlIcon); - const findAdditionalActionsMenu = () => wrapper.findComponent(GlDropdown); - const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); - - const mountComponent = (propsData = defaultProps) => { - wrapper = shallowMount(component, { - stubs: { - GlSprintf, - ListItem, - DetailsRow, - GlDropdown, - }, - propsData, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('checkbox', () => { - it('exists', () => { - mountComponent(); - - expect(findCheckbox().exists()).toBe(true); - }); - - it("does not exist when the row can't be deleted", () => { - const customTag = { ...tag, canDelete: false }; - - mountComponent({ ...defaultProps, tag: customTag }); - - expect(findCheckbox().exists()).toBe(false); - }); - - it.each` - digest | disabled - ${'foo'} | ${true} - ${null} | ${false} - ${null} | ${true} - ${'foo'} | ${true} - `('is disabled when the digest $digest and disabled is $disabled', ({ digest, disabled }) => { - mountComponent({ tag: { ...tag, digest }, disabled }); - - expect(findCheckbox().attributes('disabled')).toBe('true'); - }); - - it('is wired to the selected prop', () => { - mountComponent({ ...defaultProps, selected: true }); - - expect(findCheckbox().attributes('checked')).toBe('true'); - }); - - it('when changed emit a select event', () => { - mountComponent(); - - findCheckbox().vm.$emit('change'); - - expect(wrapper.emitted('select')).toEqual([[]]); - }); - }); - - describe('tag name', () => { - it('exists', () => { - mountComponent(); - - expect(findName().exists()).toBe(true); - }); - - it('has the correct text', () => { - mountComponent(); - - expect(findName().text()).toBe(tag.name); - }); - - it('has a tooltip', () => { - mountComponent(); - - const tooltip = getBinding(findName().element, 'gl-tooltip'); - - expect(tooltip.value.title).toBe(tag.name); - }); - - it('on mobile has mw-s class', () => { - mountComponent({ ...defaultProps, isMobile: true }); - - expect(findName().classes('mw-s')).toBe(true); - }); - }); - - describe('clipboard button', () => { - it('exist if tag.location exist', () => { - mountComponent(); - - expect(findClipboardButton().exists()).toBe(true); - }); - - it('is hidden if tag does not have a location', () => { - mountComponent({ ...defaultProps, tag: { ...tag, location: null } }); - - expect(findClipboardButton().exists()).toBe(false); - }); - - it('has the correct props/attributes', () => { - mountComponent(); - - expect(findClipboardButton().attributes()).toMatchObject({ - text: tag.location, - title: tag.location, - }); - }); - - it('is disabled when the component is disabled', () => { - mountComponent({ ...defaultProps, disabled: true }); - - expect(findClipboardButton().attributes('disabled')).toBe('true'); - }); - }); - - describe('warning icon', () => { - it('is normally hidden', () => { - mountComponent(); - - expect(findWarningIcon().exists()).toBe(false); - }); - - it('is shown when the tag is broken', () => { - mountComponent({ tag: { ...tag, digest: null } }); - - expect(findWarningIcon().exists()).toBe(true); - }); - - it('has an appropriate tooltip', () => { - mountComponent({ tag: { ...tag, digest: null } }); - - const tooltip = getBinding(findWarningIcon().element, 'gl-tooltip'); - expect(tooltip.value.title).toBe(MISSING_MANIFEST_WARNING_TOOLTIP); - }); - }); - - describe('size', () => { - it('exists', () => { - mountComponent(); - - expect(findSize().exists()).toBe(true); - }); - - it('contains the totalSize and layers', () => { - mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1024', layers: 10 } }); - - expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers'); - }); - - it('when totalSize is giantic', () => { - mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1099511627776', layers: 2 } }); - - expect(findSize().text()).toMatchInterpolatedText('1024.00 GiB · 2 layers'); - }); - - it('when totalSize is missing', () => { - mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '0', layers: 10 } }); - - expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`); - }); - - it('when layers are missing', () => { - mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1024' } }); - - expect(findSize().text()).toMatchInterpolatedText('1.00 KiB'); - }); - - it('when there is 1 layer', () => { - mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '0', layers: 1 } }); - - expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 1 layer`); - }); - }); - - describe('time', () => { - it('exists', () => { - mountComponent(); - - expect(findTime().exists()).toBe(true); - }); - - it('has the correct text', () => { - mountComponent(); - - expect(findTime().text()).toBe('Published'); - }); - - it('contains time_ago_tooltip component', () => { - mountComponent(); - - expect(findTimeAgoTooltip().exists()).toBe(true); - }); - - it('pass the correct props to time ago tooltip', () => { - mountComponent(); - - expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.createdAt }); - }); - }); - - describe('digest', () => { - it('exists', () => { - mountComponent(); - - expect(findShortRevision().exists()).toBe(true); - }); - - it('has the correct text', () => { - mountComponent(); - - expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 2cf3d2f'); - }); - - it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => { - mountComponent({ tag: { ...tag, digest: null } }); - - expect(findShortRevision().text()).toMatchInterpolatedText(`Digest: ${NOT_AVAILABLE_TEXT}`); - }); - }); - - describe('additional actions menu', () => { - it('exists', () => { - mountComponent(); - - expect(findAdditionalActionsMenu().exists()).toBe(true); - }); - - it('has the correct props', () => { - mountComponent(); - - expect(findAdditionalActionsMenu().props()).toMatchObject({ - icon: 'ellipsis_v', - text: 'More actions', - textSrOnly: true, - category: 'tertiary', - right: true, - }); - }); - - it.each` - canDelete | digest | disabled | buttonDisabled - ${true} | ${null} | ${true} | ${true} - ${false} | ${'foo'} | ${true} | ${true} - ${false} | ${null} | ${true} | ${true} - ${true} | ${'foo'} | ${true} | ${true} - ${true} | ${'foo'} | ${false} | ${false} - `( - 'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled', - ({ canDelete, digest, disabled, buttonDisabled }) => { - mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled }); - - expect(findAdditionalActionsMenu().props('disabled')).toBe(buttonDisabled); - expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(buttonDisabled); - expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(buttonDisabled); - }, - ); - - describe('delete button', () => { - it('exists and has the correct attrs', () => { - mountComponent(); - - expect(findDeleteButton().exists()).toBe(true); - expect(findDeleteButton().attributes()).toMatchObject({ - variant: 'danger', - }); - expect(findDeleteButton().text()).toBe(REMOVE_TAG_BUTTON_TITLE); - }); - - it('delete event emits delete', () => { - mountComponent(); - - findDeleteButton().vm.$emit('click'); - - expect(wrapper.emitted('delete')).toEqual([[]]); - }); - }); - }); - - describe('details rows', () => { - describe('when the tag has a digest', () => { - it('has 3 details rows', async () => { - mountComponent(); - await nextTick(); - - expect(findDetailsRows().length).toBe(3); - }); - - describe.each` - name | finderFunction | text | icon | clipboard - ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 UTC on 2020-11-03'} | ${'clock'} | ${false} - ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} - ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} - `('$name details row', ({ finderFunction, text, icon, clipboard }) => { - it(`has ${text} as text`, async () => { - mountComponent(); - await nextTick(); - - expect(finderFunction().text()).toMatchInterpolatedText(text); - }); - - it(`has the ${icon} icon`, async () => { - mountComponent(); - await nextTick(); - - expect(finderFunction().props('icon')).toBe(icon); - }); - - if (clipboard) { - it(`clipboard button exist`, async () => { - mountComponent(); - await nextTick(); - - expect(finderFunction().find(ClipboardButton).exists()).toBe(clipboard); - }); - - it('is disabled when the component is disabled', async () => { - mountComponent({ ...defaultProps, disabled: true }); - await nextTick(); - - expect(finderFunction().findComponent(ClipboardButton).attributes('disabled')).toBe( - 'true', - ); - }); - } - }); - }); - - describe('when the tag does not have a digest', () => { - it('hides the details rows', async () => { - mountComponent({ tag: { ...tag, digest: null } }); - - await nextTick(); - expect(findDetailsRows().length).toBe(0); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js deleted file mode 100644 index 51934cd074d..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js +++ /dev/null @@ -1,311 +0,0 @@ -import { GlButton, GlKeysetPagination } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import EmptyTagsState from '~/registry/explorer/components/details_page/empty_state.vue'; -import component from '~/registry/explorer/components/details_page/tags_list.vue'; -import TagsListRow from '~/registry/explorer/components/details_page/tags_list_row.vue'; -import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; -import { TAGS_LIST_TITLE, REMOVE_TAGS_BUTTON_TITLE } from '~/registry/explorer/constants/index'; -import getContainerRepositoryTagsQuery from '~/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; -import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; - -const localVue = createLocalVue(); - -describe('Tags List', () => { - let wrapper; - let apolloProvider; - const tags = [...tagsMock]; - const readOnlyTags = tags.map((t) => ({ ...t, canDelete: false })); - - const findTagsListRow = () => wrapper.findAll(TagsListRow); - const findDeleteButton = () => wrapper.find(GlButton); - const findListTitle = () => wrapper.find('[data-testid="list-title"]'); - const findPagination = () => wrapper.find(GlKeysetPagination); - const findEmptyState = () => wrapper.find(EmptyTagsState); - const findTagsLoader = () => wrapper.find(TagsLoader); - - const waitForApolloRequestRender = async () => { - await waitForPromises(); - await nextTick(); - }; - - const mountComponent = ({ - propsData = { isMobile: false, id: 1 }, - resolver = jest.fn().mockResolvedValue(imageTagsMock()), - } = {}) => { - localVue.use(VueApollo); - - const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]]; - - apolloProvider = createMockApollo(requestHandlers); - wrapper = shallowMount(component, { - localVue, - apolloProvider, - propsData, - provide() { - return { - config: {}, - }; - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('List title', () => { - it('exists', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findListTitle().exists()).toBe(true); - }); - - it('has the correct text', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findListTitle().text()).toBe(TAGS_LIST_TITLE); - }); - }); - - describe('delete button', () => { - it.each` - inputTags | isMobile | isVisible - ${tags} | ${false} | ${true} - ${tags} | ${true} | ${false} - ${readOnlyTags} | ${false} | ${false} - ${readOnlyTags} | ${true} | ${false} - `( - 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile', - async ({ inputTags, isMobile, isVisible }) => { - mountComponent({ - propsData: { tags: inputTags, isMobile, id: 1 }, - resolver: jest.fn().mockResolvedValue(imageTagsMock(inputTags)), - }); - - await waitForApolloRequestRender(); - - expect(findDeleteButton().exists()).toBe(isVisible); - }, - ); - - it('has the correct text', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE); - }); - - it('has the correct props', async () => { - mountComponent(); - await waitForApolloRequestRender(); - - expect(findDeleteButton().attributes()).toMatchObject({ - category: 'secondary', - variant: 'danger', - }); - }); - - it.each` - disabled | doSelect | buttonDisabled - ${true} | ${false} | ${'true'} - ${true} | ${true} | ${'true'} - ${false} | ${false} | ${'true'} - ${false} | ${true} | ${undefined} - `( - 'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag', - async ({ disabled, buttonDisabled, doSelect }) => { - mountComponent({ propsData: { tags, disabled, isMobile: false, id: 1 } }); - - await waitForApolloRequestRender(); - - if (doSelect) { - findTagsListRow().at(0).vm.$emit('select'); - await nextTick(); - } - - expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled); - }, - ); - - it('click event emits a deleted event with selected items', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - findTagsListRow().at(0).vm.$emit('select'); - findDeleteButton().vm.$emit('click'); - - expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); - }); - }); - - describe('list rows', () => { - it('one row exist for each tag', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findTagsListRow()).toHaveLength(tags.length); - }); - - it('the correct props are bound to it', async () => { - mountComponent({ propsData: { disabled: true, id: 1 } }); - - await waitForApolloRequestRender(); - - const rows = findTagsListRow(); - - expect(rows.at(0).attributes()).toMatchObject({ - first: 'true', - disabled: 'true', - }); - }); - - describe('events', () => { - it('select event update the selected items', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - findTagsListRow().at(0).vm.$emit('select'); - - await nextTick(); - - expect(findTagsListRow().at(0).attributes('selected')).toBe('true'); - }); - - it('delete event emit a delete event', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - findTagsListRow().at(0).vm.$emit('delete'); - expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); - }); - }); - }); - - describe('when the list of tags is empty', () => { - const resolver = jest.fn().mockResolvedValue(imageTagsMock([])); - - it('has the empty state', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findEmptyState().exists()).toBe(true); - }); - - it('does not show the loader', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findTagsLoader().exists()).toBe(false); - }); - - it('does not show the list', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findTagsListRow().exists()).toBe(false); - expect(findListTitle().exists()).toBe(false); - }); - }); - - describe('pagination', () => { - it('exists', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findPagination().exists()).toBe(true); - }); - - it('is hidden when loading', () => { - mountComponent(); - - expect(findPagination().exists()).toBe(false); - }); - - it('is hidden when there are no more pages', async () => { - mountComponent({ resolver: jest.fn().mockResolvedValue(imageTagsMock([])) }); - - await waitForApolloRequestRender(); - - expect(findPagination().exists()).toBe(false); - }); - - it('is wired to the correct pagination props', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findPagination().props()).toMatchObject({ - hasNextPage: tagsPageInfo.hasNextPage, - hasPreviousPage: tagsPageInfo.hasPreviousPage, - }); - }); - - it('fetch next page when user clicks next', async () => { - const resolver = jest.fn().mockResolvedValue(imageTagsMock()); - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - findPagination().vm.$emit('next'); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ after: tagsPageInfo.endCursor }), - ); - }); - - it('fetch previous page when user clicks prev', async () => { - const resolver = jest.fn().mockResolvedValue(imageTagsMock()); - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - findPagination().vm.$emit('prev'); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }), - ); - }); - }); - - describe('loading state', () => { - it.each` - isImageLoading | queryExecuting | loadingVisible - ${true} | ${true} | ${true} - ${true} | ${false} | ${true} - ${false} | ${true} | ${true} - ${false} | ${false} | ${false} - `( - 'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown', - async ({ isImageLoading, queryExecuting, loadingVisible }) => { - mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } }); - - if (!queryExecuting) { - await waitForApolloRequestRender(); - } - - expect(findTagsLoader().exists()).toBe(loadingVisible); - expect(findTagsListRow().exists()).toBe(!loadingVisible); - expect(findListTitle().exists()).toBe(!loadingVisible); - expect(findPagination().exists()).toBe(!loadingVisible); - }, - ); - }); -}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js deleted file mode 100644 index 40d84d9d4a5..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import component from '~/registry/explorer/components/details_page/tags_loader.vue'; -import { GlSkeletonLoader } from '../../stubs'; - -describe('TagsLoader component', () => { - let wrapper; - - const findGlSkeletonLoaders = () => wrapper.findAll(GlSkeletonLoader); - - const mountComponent = () => { - wrapper = shallowMount(component, { - stubs: { - GlSkeletonLoader, - }, - // set the repeat to 1 to avoid a long and verbose snapshot - loader: { - ...component.loader, - repeat: 1, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('produces the correct amount of loaders ', () => { - mountComponent(); - expect(findGlSkeletonLoaders().length).toBe(1); - }); - - it('has the correct props', () => { - mountComponent(); - expect(findGlSkeletonLoaders().at(0).props()).toMatchObject({ - width: component.loader.width, - height: component.loader.height, - }); - }); - - it('has the correct markup', () => { - mountComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap deleted file mode 100644 index 56579847468..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Registry Group Empty state to match the default snapshot 1`] = ` -
    -

    - With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. - - More Information - -

    -
    -`; diff --git a/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap deleted file mode 100644 index 46b07b4c2d6..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap +++ /dev/null @@ -1,83 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Registry Project Empty state to match the default snapshot 1`] = ` -
    -

    - With the Container Registry, every project can have its own space to store its Docker images. - - More Information - -

    - -
    - CLI Commands -
    - -

    - If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have - - Two-Factor Authentication - - enabled, use a - - Personal Access Token - - instead of a password. -

    - - - - - -

    - - You can add an image to this registry with the following commands: - -

    - - - - - - - - -
    -`; diff --git a/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js deleted file mode 100644 index 8f2c049a357..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import CleanupStatus from '~/registry/explorer/components/list_page/cleanup_status.vue'; -import { - CLEANUP_TIMED_OUT_ERROR_MESSAGE, - CLEANUP_STATUS_SCHEDULED, - CLEANUP_STATUS_ONGOING, - CLEANUP_STATUS_UNFINISHED, - UNFINISHED_STATUS, - UNSCHEDULED_STATUS, - SCHEDULED_STATUS, - ONGOING_STATUS, -} from '~/registry/explorer/constants'; - -describe('cleanup_status', () => { - let wrapper; - - const findMainIcon = () => wrapper.findByTestId('main-icon'); - const findExtraInfoIcon = () => wrapper.findByTestId('extra-info'); - - const mountComponent = (propsData = { status: SCHEDULED_STATUS }) => { - wrapper = shallowMountExtended(CleanupStatus, { - propsData, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it.each` - status | visible | text - ${UNFINISHED_STATUS} | ${true} | ${CLEANUP_STATUS_UNFINISHED} - ${SCHEDULED_STATUS} | ${true} | ${CLEANUP_STATUS_SCHEDULED} - ${ONGOING_STATUS} | ${true} | ${CLEANUP_STATUS_ONGOING} - ${UNSCHEDULED_STATUS} | ${false} | ${''} - `( - 'when the status is $status is $visible that the component is mounted and has the correct text', - ({ status, visible, text }) => { - mountComponent({ status }); - - expect(findMainIcon().exists()).toBe(visible); - expect(wrapper.text()).toBe(text); - }, - ); - - describe('main icon', () => { - it('exists', () => { - mountComponent(); - - expect(findMainIcon().exists()).toBe(true); - }); - - it(`has the orange class when the status is ${UNFINISHED_STATUS}`, () => { - mountComponent({ status: UNFINISHED_STATUS }); - - expect(findMainIcon().classes('gl-text-orange-500')).toBe(true); - }); - }); - - describe('extra info icon', () => { - it.each` - status | visible - ${UNFINISHED_STATUS} | ${true} - ${SCHEDULED_STATUS} | ${false} - ${ONGOING_STATUS} | ${false} - `( - 'when the status is $status is $visible that the extra icon is visible', - ({ status, visible }) => { - mountComponent({ status }); - - expect(findExtraInfoIcon().exists()).toBe(visible); - }, - ); - - it(`has a tooltip`, () => { - mountComponent({ status: UNFINISHED_STATUS }); - - const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip'); - - expect(tooltip.value.title).toBe(CLEANUP_TIMED_OUT_ERROR_MESSAGE); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js deleted file mode 100644 index 8ca8fca65ed..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js +++ /dev/null @@ -1,94 +0,0 @@ -import { GlDropdown } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import QuickstartDropdown from '~/registry/explorer/components/list_page/cli_commands.vue'; -import { - QUICK_START, - LOGIN_COMMAND_LABEL, - COPY_LOGIN_TITLE, - BUILD_COMMAND_LABEL, - COPY_BUILD_TITLE, - PUSH_COMMAND_LABEL, - COPY_PUSH_TITLE, -} from '~/registry/explorer/constants'; -import Tracking from '~/tracking'; -import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; - -import { dockerCommands } from '../../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('cli_commands', () => { - let wrapper; - - const config = { - repositoryUrl: 'foo', - registryHostUrlWithPort: 'bar', - }; - - const findDropdownButton = () => wrapper.find(GlDropdown); - const findCodeInstruction = () => wrapper.findAll(CodeInstruction); - - const mountComponent = () => { - wrapper = mount(QuickstartDropdown, { - localVue, - provide() { - return { - config, - ...dockerCommands, - }; - }, - }); - }; - - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - mountComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('shows the correct text on the button', () => { - expect(findDropdownButton().text()).toContain(QUICK_START); - }); - - it('clicking on the dropdown emit a tracking event', () => { - findDropdownButton().vm.$emit('shown'); - expect(Tracking.event).toHaveBeenCalledWith( - undefined, - 'click_dropdown', - expect.objectContaining({ label: 'quickstart_dropdown' }), - ); - }); - - describe.each` - index | labelText | titleText | command | trackedEvent - ${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${dockerCommands.dockerLoginCommand} | ${'click_copy_login'} - ${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${dockerCommands.dockerBuildCommand} | ${'click_copy_build'} - ${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${dockerCommands.dockerPushCommand} | ${'click_copy_push'} - `('code instructions at $index', ({ index, labelText, titleText, command, trackedEvent }) => { - let codeInstruction; - - beforeEach(() => { - codeInstruction = findCodeInstruction().at(index); - }); - - it('exists', () => { - expect(codeInstruction.exists()).toBe(true); - }); - - it(`has the correct props`, () => { - expect(codeInstruction.props()).toMatchObject({ - label: labelText, - instruction: command, - copyText: titleText, - trackingAction: trackedEvent, - trackingLabel: 'quickstart_dropdown', - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js b/spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js deleted file mode 100644 index 989a60625e2..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import groupEmptyState from '~/registry/explorer/components/list_page/group_empty_state.vue'; -import { GlEmptyState } from '../../stubs'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('Registry Group Empty state', () => { - let wrapper; - const config = { - noContainersImage: 'foo', - helpPagePath: 'baz', - }; - - beforeEach(() => { - wrapper = shallowMount(groupEmptyState, { - localVue, - stubs: { - GlEmptyState, - GlSprintf, - }, - provide() { - return { config }; - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('to match the default snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js deleted file mode 100644 index db0f869ab52..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ /dev/null @@ -1,223 +0,0 @@ -import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import DeleteButton from '~/registry/explorer/components/delete_button.vue'; -import CleanupStatus from '~/registry/explorer/components/list_page/cleanup_status.vue'; -import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; -import { - ROW_SCHEDULED_FOR_DELETION, - LIST_DELETE_BUTTON_DISABLED, - REMOVE_REPOSITORY_LABEL, - IMAGE_DELETE_SCHEDULED_STATUS, - SCHEDULED_STATUS, - ROOT_IMAGE_TEXT, -} from '~/registry/explorer/constants'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import ListItem from '~/vue_shared/components/registry/list_item.vue'; -import { imagesListResponse } from '../../mock_data'; -import { RouterLink } from '../../stubs'; - -describe('Image List Row', () => { - let wrapper; - const [item] = imagesListResponse; - - const findDetailsLink = () => wrapper.find('[data-testid="details-link"]'); - const findTagsCount = () => wrapper.find('[data-testid="tags-count"]'); - const findDeleteBtn = () => wrapper.findComponent(DeleteButton); - const findClipboardButton = () => wrapper.findComponent(ClipboardButton); - const findCleanupStatus = () => wrapper.findComponent(CleanupStatus); - const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findListItemComponent = () => wrapper.findComponent(ListItem); - - const mountComponent = (props) => { - wrapper = shallowMount(Component, { - stubs: { - RouterLink, - GlSprintf, - ListItem, - }, - propsData: { - item, - ...props, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('list item component', () => { - describe('tooltip', () => { - it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => { - mountComponent(); - - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip).toBeDefined(); - expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION); - }); - - it('is disabled when item is being deleted', () => { - mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); - - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip.value.disabled).toBe(false); - }); - }); - - it('is disabled when the item is in deleting status', () => { - mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); - - expect(findListItemComponent().props('disabled')).toBe(true); - }); - }); - - describe('image title and path', () => { - it('contains a link to the details page', () => { - mountComponent(); - - const link = findDetailsLink(); - expect(link.text()).toBe(item.path); - expect(findDetailsLink().props('to')).toMatchObject({ - name: 'details', - params: { - id: getIdFromGraphQLId(item.id), - }, - }); - }); - - it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => { - mountComponent({ item: { ...item, name: '' } }); - - expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`); - }); - - it('contains a clipboard button', () => { - mountComponent(); - const button = findClipboardButton(); - expect(button.exists()).toBe(true); - expect(button.props('text')).toBe(item.location); - expect(button.props('title')).toBe(item.location); - }); - - describe('cleanup status component', () => { - it.each` - expirationPolicyCleanupStatus | shown - ${null} | ${false} - ${SCHEDULED_STATUS} | ${true} - `( - 'when expirationPolicyCleanupStatus is $expirationPolicyCleanupStatus it is $shown that the component exists', - ({ expirationPolicyCleanupStatus, shown }) => { - mountComponent({ item: { ...item, expirationPolicyCleanupStatus } }); - - expect(findCleanupStatus().exists()).toBe(shown); - - if (shown) { - expect(findCleanupStatus().props()).toMatchObject({ - status: expirationPolicyCleanupStatus, - }); - } - }, - ); - }); - - describe('when the item is deleting', () => { - beforeEach(() => { - mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); - }); - - it('the router link is disabled', () => { - // we check the event prop as is the only workaround to disable a router link - expect(findDetailsLink().props('event')).toBe(''); - }); - it('the clipboard button is disabled', () => { - expect(findClipboardButton().attributes('disabled')).toBe('true'); - }); - }); - }); - - describe('delete button', () => { - it('exists', () => { - mountComponent(); - expect(findDeleteBtn().exists()).toBe(true); - }); - - it('has the correct props', () => { - mountComponent(); - - expect(findDeleteBtn().props()).toMatchObject({ - title: REMOVE_REPOSITORY_LABEL, - tooltipDisabled: item.canDelete, - tooltipTitle: LIST_DELETE_BUTTON_DISABLED, - }); - }); - - it('emits a delete event', () => { - mountComponent(); - - findDeleteBtn().vm.$emit('delete'); - expect(wrapper.emitted('delete')).toEqual([[item]]); - }); - - it.each` - canDelete | status | state - ${false} | ${''} | ${true} - ${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true} - ${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true} - ${true} | ${''} | ${false} - `( - 'disabled is $state when canDelete is $canDelete and status is $status', - ({ canDelete, status, state }) => { - mountComponent({ item: { ...item, canDelete, status } }); - - expect(findDeleteBtn().props('disabled')).toBe(state); - }, - ); - }); - - describe('tags count', () => { - it('exists', () => { - mountComponent(); - expect(findTagsCount().exists()).toBe(true); - }); - - it('contains a tag icon', () => { - mountComponent(); - const icon = findTagsCount().find(GlIcon); - expect(icon.exists()).toBe(true); - expect(icon.props('name')).toBe('tag'); - }); - - describe('loading state', () => { - it('shows a loader when metadataLoading is true', () => { - mountComponent({ metadataLoading: true }); - - expect(findSkeletonLoader().exists()).toBe(true); - }); - - it('hides the tags count while loading', () => { - mountComponent({ metadataLoading: true }); - - expect(findTagsCount().exists()).toBe(false); - }); - }); - - describe('tags count text', () => { - it('with one tag in the image', () => { - mountComponent({ item: { ...item, tagsCount: 1 } }); - - expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag'); - }); - it('with more than one tag in the image', () => { - mountComponent({ item: { ...item, tagsCount: 3 } }); - - expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags'); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js deleted file mode 100644 index d7dd825ca3e..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js +++ /dev/null @@ -1,88 +0,0 @@ -import { GlKeysetPagination } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Component from '~/registry/explorer/components/list_page/image_list.vue'; -import ImageListRow from '~/registry/explorer/components/list_page/image_list_row.vue'; - -import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data'; - -describe('Image List', () => { - let wrapper; - - const findRow = () => wrapper.findAll(ImageListRow); - const findPagination = () => wrapper.find(GlKeysetPagination); - - const mountComponent = (props) => { - wrapper = shallowMount(Component, { - propsData: { - images: imagesListResponse, - pageInfo: defaultPageInfo, - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('list', () => { - it('contains one list element for each image', () => { - mountComponent(); - - expect(findRow().length).toBe(imagesListResponse.length); - }); - - it('when delete event is emitted on the row it emits up a delete event', () => { - mountComponent(); - - findRow().at(0).vm.$emit('delete', 'foo'); - expect(wrapper.emitted('delete')).toEqual([['foo']]); - }); - - it('passes down the metadataLoading prop', () => { - mountComponent({ metadataLoading: true }); - expect(findRow().at(0).props('metadataLoading')).toBe(true); - }); - }); - - describe('pagination', () => { - it('exists', () => { - mountComponent(); - - expect(findPagination().exists()).toBe(true); - }); - - it.each` - hasNextPage | hasPreviousPage | isVisible - ${true} | ${true} | ${true} - ${true} | ${false} | ${true} - ${false} | ${true} | ${true} - `( - 'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible', - ({ hasNextPage, hasPreviousPage, isVisible }) => { - mountComponent({ pageInfo: { ...defaultPageInfo, hasNextPage, hasPreviousPage } }); - - expect(findPagination().exists()).toBe(isVisible); - expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage); - expect(findPagination().props('hasNextPage')).toBe(hasNextPage); - }, - ); - - it('emits "prev-page" when the user clicks the back page button', () => { - mountComponent(); - - findPagination().vm.$emit('prev'); - - expect(wrapper.emitted('prev-page')).toEqual([[]]); - }); - - it('emits "next-page" when the user clicks the forward page button', () => { - mountComponent(); - - findPagination().vm.$emit('next'); - - expect(wrapper.emitted('next-page')).toEqual([[]]); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js b/spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js deleted file mode 100644 index 111aa45f231..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import projectEmptyState from '~/registry/explorer/components/list_page/project_empty_state.vue'; -import { dockerCommands } from '../../mock_data'; -import { GlEmptyState } from '../../stubs'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('Registry Project Empty state', () => { - let wrapper; - const config = { - repositoryUrl: 'foo', - registryHostUrlWithPort: 'bar', - helpPagePath: 'baz', - twoFactorAuthHelpLink: 'barBaz', - personalAccessTokensHelpLink: 'fooBaz', - noContainersImage: 'bazFoo', - }; - - beforeEach(() => { - wrapper = shallowMount(projectEmptyState, { - localVue, - stubs: { - GlEmptyState, - GlSprintf, - }, - provide() { - return { - config, - ...dockerCommands, - }; - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('to match the default snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js deleted file mode 100644 index 11a3acd9eb9..00000000000 --- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js +++ /dev/null @@ -1,135 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Component from '~/registry/explorer/components/list_page/registry_header.vue'; -import { - CONTAINER_REGISTRY_TITLE, - LIST_INTRO_TEXT, - EXPIRATION_POLICY_DISABLED_TEXT, -} from '~/registry/explorer/constants'; -import TitleArea from '~/vue_shared/components/registry/title_area.vue'; - -jest.mock('~/lib/utils/datetime_utility', () => ({ - approximateDuration: jest.fn(), - calculateRemainingMilliseconds: jest.fn(), -})); - -describe('registry_header', () => { - let wrapper; - - const findTitleArea = () => wrapper.find(TitleArea); - const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); - const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]'); - const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]'); - - const mountComponent = (propsData, slots) => { - wrapper = shallowMount(Component, { - stubs: { - GlSprintf, - TitleArea, - }, - propsData, - slots, - }); - return wrapper.vm.$nextTick(); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('header', () => { - it('has a title', () => { - mountComponent({ metadataLoading: true }); - - expect(findTitleArea().props()).toMatchObject({ - title: CONTAINER_REGISTRY_TITLE, - metadataLoading: true, - }); - }); - - it('has a commands slot', () => { - mountComponent(null, { commands: '
    baz
    ' }); - - expect(findCommandsSlot().text()).toBe('baz'); - }); - - describe('sub header parts', () => { - describe('images count', () => { - it('exists', async () => { - await mountComponent({ imagesCount: 1 }); - - expect(findImagesCountSubHeader().exists()).toBe(true); - }); - - it('when there is one image', async () => { - await mountComponent({ imagesCount: 1 }); - - expect(findImagesCountSubHeader().props()).toMatchObject({ - text: '1 Image repository', - icon: 'container-image', - }); - }); - - it('when there is more than one image', async () => { - await mountComponent({ imagesCount: 3 }); - - expect(findImagesCountSubHeader().props('text')).toBe('3 Image repositories'); - }); - }); - - describe('expiration policy', () => { - it('when is disabled', async () => { - await mountComponent({ - expirationPolicy: { enabled: false }, - expirationPolicyHelpPagePath: 'foo', - imagesCount: 1, - }); - - const text = findExpirationPolicySubHeader(); - expect(text.exists()).toBe(true); - expect(text.props()).toMatchObject({ - text: EXPIRATION_POLICY_DISABLED_TEXT, - icon: 'expire', - size: 'xl', - }); - }); - - it('when is enabled', async () => { - await mountComponent({ - expirationPolicy: { enabled: true }, - expirationPolicyHelpPagePath: 'foo', - imagesCount: 1, - }); - - const text = findExpirationPolicySubHeader(); - expect(text.exists()).toBe(true); - expect(text.props('text')).toBe('Expiration policy will run in '); - }); - it('when the expiration policy is completely disabled', async () => { - await mountComponent({ - expirationPolicy: { enabled: true }, - expirationPolicyHelpPagePath: 'foo', - imagesCount: 1, - hideExpirationPolicyData: true, - }); - - const text = findExpirationPolicySubHeader(); - expect(text.exists()).toBe(false); - }); - }); - }); - }); - - describe('info messages', () => { - describe('default message', () => { - it('is correctly bound to title_area props', () => { - mountComponent({ helpPagePath: 'foo' }); - - expect(findTitleArea().props('infoMessages')).toEqual([ - { text: LIST_INTRO_TEXT, link: 'foo' }, - ]); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js deleted file mode 100644 index 487f33594c1..00000000000 --- a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js +++ /dev/null @@ -1,78 +0,0 @@ -import { mount } from '@vue/test-utils'; - -import component from '~/registry/explorer/components/registry_breadcrumb.vue'; - -describe('Registry Breadcrumb', () => { - let wrapper; - const nameGenerator = jest.fn(); - - const routes = [ - { name: 'list', path: '/', meta: { nameGenerator, root: true } }, - { name: 'details', path: '/:id', meta: { nameGenerator } }, - ]; - - const mountComponent = ($route) => { - wrapper = mount(component, { - mocks: { - $route, - $router: { - options: { - routes, - }, - }, - }, - }); - }; - - beforeEach(() => { - nameGenerator.mockClear(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when is rootRoute', () => { - beforeEach(() => { - mountComponent(routes[0]); - }); - - it('renders', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('contains only a single router-link to list', () => { - const links = wrapper.findAll('a'); - - expect(links).toHaveLength(1); - expect(links.at(0).attributes('href')).toBe('/'); - }); - - it('the link text is calculated by nameGenerator', () => { - expect(nameGenerator).toHaveBeenCalledTimes(1); - }); - }); - - describe('when is not rootRoute', () => { - beforeEach(() => { - mountComponent(routes[1]); - }); - - it('renders', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('contains two router-links to list and details', () => { - const links = wrapper.findAll('a'); - - expect(links).toHaveLength(2); - expect(links.at(0).attributes('href')).toBe('/'); - expect(links.at(1).attributes('href')).toBe('#'); - }); - - it('the link text is calculated by nameGenerator', () => { - expect(nameGenerator).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js deleted file mode 100644 index 6a835a28807..00000000000 --- a/spec/frontend/registry/explorer/mock_data.js +++ /dev/null @@ -1,269 +0,0 @@ -export const imagesListResponse = [ - { - __typename: 'ContainerRepository', - id: 'gid://gitlab/ContainerRepository/26', - name: 'rails-12009', - path: 'gitlab-org/gitlab-test/rails-12009', - status: null, - location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009', - canDelete: true, - createdAt: '2020-11-03T13:29:21Z', - expirationPolicyStartedAt: null, - expirationPolicyCleanupStatus: 'UNSCHEDULED', - }, - { - __typename: 'ContainerRepository', - id: 'gid://gitlab/ContainerRepository/11', - name: 'rails-20572', - path: 'gitlab-org/gitlab-test/rails-20572', - status: null, - location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572', - canDelete: true, - createdAt: '2020-09-21T06:57:43Z', - expirationPolicyStartedAt: null, - expirationPolicyCleanupStatus: 'UNSCHEDULED', - }, -]; - -export const pageInfo = { - hasNextPage: true, - hasPreviousPage: true, - startCursor: 'eyJpZCI6IjI2In0', - endCursor: 'eyJpZCI6IjgifQ', - __typename: 'ContainerRepositoryConnection', -}; - -export const graphQLImageListMock = { - data: { - project: { - __typename: 'Project', - containerRepositoriesCount: 2, - containerRepositories: { - __typename: 'ContainerRepositoryConnection', - nodes: imagesListResponse, - pageInfo, - }, - }, - }, -}; - -export const graphQLEmptyImageListMock = { - data: { - project: { - __typename: 'Project', - containerRepositoriesCount: 2, - containerRepositories: { - __typename: 'ContainerRepositoryConnection', - nodes: [], - pageInfo, - }, - }, - }, -}; - -export const graphQLEmptyGroupImageListMock = { - data: { - group: { - __typename: 'Group', - containerRepositoriesCount: 2, - containerRepositories: { - __typename: 'ContainerRepositoryConnection', - nodes: [], - pageInfo, - }, - }, - }, -}; - -export const deletedContainerRepository = { - id: 'gid://gitlab/ContainerRepository/11', - status: 'DELETE_SCHEDULED', - path: 'gitlab-org/gitlab-test/rails-12009', - __typename: 'ContainerRepository', -}; - -export const graphQLImageDeleteMock = { - data: { - destroyContainerRepository: { - containerRepository: { - ...deletedContainerRepository, - }, - errors: [], - __typename: 'DestroyContainerRepositoryPayload', - }, - }, -}; - -export const graphQLImageDeleteMockError = { - data: { - destroyContainerRepository: { - containerRepository: { - ...deletedContainerRepository, - }, - errors: ['foo'], - __typename: 'DestroyContainerRepositoryPayload', - }, - }, -}; - -export const containerRepositoryMock = { - id: 'gid://gitlab/ContainerRepository/26', - name: 'rails-12009', - path: 'gitlab-org/gitlab-test/rails-12009', - status: null, - location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009', - canDelete: true, - createdAt: '2020-11-03T13:29:21Z', - updatedAt: '2020-11-03T13:29:21Z', - expirationPolicyStartedAt: null, - expirationPolicyCleanupStatus: 'UNSCHEDULED', - project: { - visibility: 'public', - path: 'gitlab-test', - containerExpirationPolicy: { - enabled: false, - nextRunAt: '2020-11-27T08:59:27Z', - }, - __typename: 'Project', - }, -}; - -export const tagsPageInfo = { - __typename: 'PageInfo', - hasNextPage: true, - hasPreviousPage: true, - startCursor: 'MQ', - endCursor: 'MTA', -}; - -export const tagsMock = [ - { - digest: 'sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062', - location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-24753', - path: 'gitlab-org/gitlab-test/rails-12009:beta-24753', - name: 'beta-24753', - revision: 'c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b', - shortRevision: 'c2613843a', - createdAt: '2020-11-03T13:29:38+00:00', - totalSize: '1099511627776', - canDelete: true, - __typename: 'ContainerRepositoryTag', - }, - { - digest: 'sha256:7f94f97dff89ffd122cafe50cd32329adf682356a7a96f69cbfe313ee589791c', - location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-31075', - path: 'gitlab-org/gitlab-test/rails-12009:beta-31075', - name: 'beta-31075', - revision: 'df44e7228f0f255c73e35b6f0699624a615f42746e3e8e2e4b3804a6d6fc3292', - shortRevision: 'df44e7228', - createdAt: '2020-11-03T13:29:32+00:00', - totalSize: '536870912000', - canDelete: true, - __typename: 'ContainerRepositoryTag', - }, -]; - -export const imageTagsMock = (nodes = tagsMock) => ({ - data: { - containerRepository: { - id: containerRepositoryMock.id, - tags: { - nodes, - pageInfo: { ...tagsPageInfo }, - __typename: 'ContainerRepositoryTagConnection', - }, - __typename: 'ContainerRepositoryDetails', - }, - }, -}); - -export const imageTagsCountMock = (override) => ({ - data: { - containerRepository: { - id: containerRepositoryMock.id, - tagsCount: 13, - ...override, - }, - }, -}); - -export const graphQLImageDetailsMock = (override) => ({ - data: { - containerRepository: { - ...containerRepositoryMock, - - tags: { - nodes: tagsMock, - pageInfo: { ...tagsPageInfo }, - __typename: 'ContainerRepositoryTagConnection', - }, - __typename: 'ContainerRepositoryDetails', - ...override, - }, - }, -}); - -export const graphQLImageDetailsEmptyTagsMock = { - data: { - containerRepository: { - ...containerRepositoryMock, - tags: { - nodes: [], - pageInfo: { - __typename: 'PageInfo', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - endCursor: '', - }, - __typename: 'ContainerRepositoryTagConnection', - }, - __typename: 'ContainerRepositoryDetails', - }, - }, -}; - -export const graphQLDeleteImageRepositoryTagsMock = { - data: { - destroyContainerRepositoryTags: { - deletedTagNames: [], - errors: [], - __typename: 'DestroyContainerRepositoryTagsPayload', - }, - }, -}; - -export const dockerCommands = { - dockerBuildCommand: 'foofoo', - dockerPushCommand: 'barbar', - dockerLoginCommand: 'bazbaz', -}; - -export const graphQLProjectImageRepositoriesDetailsMock = { - data: { - project: { - containerRepositories: { - nodes: [ - { - id: 'gid://gitlab/ContainerRepository/26', - tagsCount: 4, - __typename: 'ContainerRepository', - }, - { - id: 'gid://gitlab/ContainerRepository/11', - tagsCount: 1, - __typename: 'ContainerRepository', - }, - ], - __typename: 'ContainerRepositoryConnection', - }, - __typename: 'Project', - }, - }, -}; - -export const graphQLEmptyImageDetailsMock = { - data: { - containerRepository: null, - }, -}; diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js deleted file mode 100644 index 21af9dcc60f..00000000000 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ /dev/null @@ -1,521 +0,0 @@ -import { GlKeysetPagination } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import DeleteImage from '~/registry/explorer/components/delete_image.vue'; -import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue'; -import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue'; -import EmptyTagsState from '~/registry/explorer/components/details_page/empty_state.vue'; -import PartialCleanupAlert from '~/registry/explorer/components/details_page/partial_cleanup_alert.vue'; -import StatusAlert from '~/registry/explorer/components/details_page/status_alert.vue'; -import TagsList from '~/registry/explorer/components/details_page/tags_list.vue'; -import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; - -import { - UNFINISHED_STATUS, - DELETE_SCHEDULED, - ALERT_DANGER_IMAGE, - MISSING_OR_DELETED_IMAGE_BREADCRUMB, - ROOT_IMAGE_TEXT, -} from '~/registry/explorer/constants'; -import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; -import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; - -import component from '~/registry/explorer/pages/details.vue'; -import Tracking from '~/tracking'; - -import { - graphQLImageDetailsMock, - graphQLDeleteImageRepositoryTagsMock, - containerRepositoryMock, - graphQLEmptyImageDetailsMock, - tagsMock, -} from '../mock_data'; -import { DeleteModal } from '../stubs'; - -const localVue = createLocalVue(); - -describe('Details Page', () => { - let wrapper; - let apolloProvider; - - const findDeleteModal = () => wrapper.find(DeleteModal); - const findPagination = () => wrapper.find(GlKeysetPagination); - const findTagsLoader = () => wrapper.find(TagsLoader); - const findTagsList = () => wrapper.find(TagsList); - const findDeleteAlert = () => wrapper.find(DeleteAlert); - const findDetailsHeader = () => wrapper.find(DetailsHeader); - const findEmptyState = () => wrapper.find(EmptyTagsState); - const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); - const findStatusAlert = () => wrapper.find(StatusAlert); - const findDeleteImage = () => wrapper.find(DeleteImage); - - const routeId = 1; - - const breadCrumbState = { - updateName: jest.fn(), - }; - - const cleanTags = tagsMock.map((t) => { - const result = { ...t }; - // eslint-disable-next-line no-underscore-dangle - delete result.__typename; - return result; - }); - - const waitForApolloRequestRender = async () => { - await waitForPromises(); - await wrapper.vm.$nextTick(); - }; - - const mountComponent = ({ - resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), - mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), - options, - config = {}, - } = {}) => { - localVue.use(VueApollo); - - const requestHandlers = [ - [getContainerRepositoryDetailsQuery, resolver], - [deleteContainerRepositoryTagsMutation, mutationResolver], - ]; - - apolloProvider = createMockApollo(requestHandlers); - - wrapper = shallowMount(component, { - localVue, - apolloProvider, - stubs: { - DeleteModal, - DeleteImage, - }, - mocks: { - $route: { - params: { - id: routeId, - }, - }, - }, - provide() { - return { - breadCrumbState, - config, - }; - }, - ...options, - }); - }; - - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when isLoading is true', () => { - it('shows the loader', () => { - mountComponent(); - - expect(findTagsLoader().exists()).toBe(true); - }); - - it('does not show the list', () => { - mountComponent(); - - expect(findTagsList().exists()).toBe(false); - }); - }); - - describe('when the image does not exist', () => { - it('does not show the default ui', async () => { - mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); - - await waitForApolloRequestRender(); - - expect(findTagsLoader().exists()).toBe(false); - expect(findDetailsHeader().exists()).toBe(false); - expect(findTagsList().exists()).toBe(false); - expect(findPagination().exists()).toBe(false); - }); - - it('shows an empty state message', async () => { - mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); - - await waitForApolloRequestRender(); - - expect(findEmptyState().exists()).toBe(true); - }); - }); - - describe('list', () => { - it('exists', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findTagsList().exists()).toBe(true); - }); - - it('has the correct props bound', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findTagsList().props()).toMatchObject({ - isMobile: false, - }); - }); - - describe('deleteEvent', () => { - describe('single item', () => { - let tagToBeDeleted; - beforeEach(async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - [tagToBeDeleted] = cleanTags; - findTagsList().vm.$emit('delete', [tagToBeDeleted]); - }); - - it('open the modal', async () => { - expect(DeleteModal.methods.show).toHaveBeenCalled(); - }); - - it('tracks a single delete event', () => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { - label: 'registry_tag_delete', - }); - }); - }); - - describe('multiple items', () => { - beforeEach(async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - findTagsList().vm.$emit('delete', cleanTags); - }); - - it('open the modal', () => { - expect(DeleteModal.methods.show).toHaveBeenCalled(); - }); - - it('tracks a single delete event', () => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { - label: 'bulk_registry_tag_delete', - }); - }); - }); - }); - }); - - describe('modal', () => { - it('exists', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findDeleteModal().exists()).toBe(true); - }); - - describe('cancel event', () => { - it('tracks cancel_delete', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - findDeleteModal().vm.$emit('cancel'); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', { - label: 'registry_tag_delete', - }); - }); - }); - - describe('confirmDelete event', () => { - let mutationResolver; - - beforeEach(() => { - mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); - mountComponent({ mutationResolver }); - - return waitForApolloRequestRender(); - }); - describe('when one item is selected to be deleted', () => { - it('calls apollo mutation with the right parameters', async () => { - findTagsList().vm.$emit('delete', [cleanTags[0]]); - - await wrapper.vm.$nextTick(); - - findDeleteModal().vm.$emit('confirmDelete'); - - expect(mutationResolver).toHaveBeenCalledWith( - expect.objectContaining({ tagNames: [cleanTags[0].name] }), - ); - }); - }); - - describe('when more than one item is selected to be deleted', () => { - it('calls apollo mutation with the right parameters', async () => { - findTagsList().vm.$emit('delete', tagsMock); - - await wrapper.vm.$nextTick(); - - findDeleteModal().vm.$emit('confirmDelete'); - - expect(mutationResolver).toHaveBeenCalledWith( - expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }), - ); - }); - }); - }); - }); - - describe('Header', () => { - it('exists', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - expect(findDetailsHeader().exists()).toBe(true); - }); - - it('has the correct props', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - expect(findDetailsHeader().props()).toMatchObject({ - image: { - name: containerRepositoryMock.name, - project: { - visibility: containerRepositoryMock.project.visibility, - }, - }, - }); - }); - }); - - describe('Delete Alert', () => { - const config = { - isAdmin: true, - garbageCollectionHelpPagePath: 'baz', - }; - const deleteAlertType = 'success_tag'; - - it('exists', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - expect(findDeleteAlert().exists()).toBe(true); - }); - - it('has the correct props', async () => { - mountComponent({ - options: { - data: () => ({ - deleteAlertType, - }), - }, - config, - }); - - await waitForApolloRequestRender(); - - expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType }); - }); - }); - - describe('Partial Cleanup Alert', () => { - const config = { - runCleanupPoliciesHelpPagePath: 'foo', - expirationPolicyHelpPagePath: 'bar', - userCalloutsPath: 'call_out_path', - userCalloutId: 'call_out_id', - showUnfinishedTagCleanupCallout: true, - }; - - describe(`when expirationPolicyCleanupStatus is ${UNFINISHED_STATUS}`, () => { - let resolver; - - beforeEach(() => { - resolver = jest.fn().mockResolvedValue( - graphQLImageDetailsMock({ - expirationPolicyCleanupStatus: UNFINISHED_STATUS, - }), - ); - }); - - it('exists', async () => { - mountComponent({ resolver, config }); - - await waitForApolloRequestRender(); - - expect(findPartialCleanupAlert().exists()).toBe(true); - }); - - it('has the correct props', async () => { - mountComponent({ resolver, config }); - - await waitForApolloRequestRender(); - - expect(findPartialCleanupAlert().props()).toEqual({ - runCleanupPoliciesHelpPagePath: config.runCleanupPoliciesHelpPagePath, - cleanupPoliciesHelpPagePath: config.expirationPolicyHelpPagePath, - }); - }); - - it('dismiss hides the component', async () => { - jest.spyOn(axios, 'post').mockReturnValue(); - - mountComponent({ resolver, config }); - - await waitForApolloRequestRender(); - - expect(findPartialCleanupAlert().exists()).toBe(true); - - findPartialCleanupAlert().vm.$emit('dismiss'); - - await wrapper.vm.$nextTick(); - - expect(axios.post).toHaveBeenCalledWith(config.userCalloutsPath, { - feature_name: config.userCalloutId, - }); - expect(findPartialCleanupAlert().exists()).toBe(false); - }); - - it('is hidden if the callout is dismissed', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findPartialCleanupAlert().exists()).toBe(false); - }); - }); - - describe(`when expirationPolicyCleanupStatus is not ${UNFINISHED_STATUS}`, () => { - it('the component is hidden', async () => { - mountComponent({ config }); - - await waitForApolloRequestRender(); - - expect(findPartialCleanupAlert().exists()).toBe(false); - }); - }); - }); - - describe('Breadcrumb connection', () => { - it('when the details are fetched updates the name', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); - }); - - it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => { - mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); - - await waitForApolloRequestRender(); - - expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB); - }); - - it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => { - mountComponent({ - resolver: jest - .fn() - .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })), - }); - - await waitForApolloRequestRender(); - - expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT); - }); - }); - - describe('when the image has a status different from null', () => { - const resolver = jest - .fn() - .mockResolvedValue(graphQLImageDetailsMock({ status: DELETE_SCHEDULED })); - it('disables all the actions', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findDetailsHeader().props('disabled')).toBe(true); - expect(findTagsList().props('disabled')).toBe(true); - }); - - it('shows a status alert', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findStatusAlert().exists()).toBe(true); - expect(findStatusAlert().props()).toMatchObject({ - status: DELETE_SCHEDULED, - }); - }); - }); - - describe('delete the image', () => { - const mountComponentAndDeleteImage = async () => { - mountComponent(); - - await waitForApolloRequestRender(); - findDetailsHeader().vm.$emit('delete'); - - await wrapper.vm.$nextTick(); - }; - - it('on delete event it deletes the image', async () => { - await mountComponentAndDeleteImage(); - - findDeleteModal().vm.$emit('confirmDelete'); - - expect(findDeleteImage().emitted('start')).toEqual([[]]); - }); - - it('binds the correct props to the modal', async () => { - await mountComponentAndDeleteImage(); - - expect(findDeleteModal().props()).toMatchObject({ - itemsToBeDeleted: [{ path: 'gitlab-org/gitlab-test/rails-12009' }], - deleteImage: true, - }); - }); - - it('binds correctly to delete-image start and end events', async () => { - mountComponent(); - - findDeleteImage().vm.$emit('start'); - - await wrapper.vm.$nextTick(); - - expect(findTagsLoader().exists()).toBe(true); - - findDeleteImage().vm.$emit('end'); - - await wrapper.vm.$nextTick(); - - expect(findTagsLoader().exists()).toBe(false); - }); - - it('binds correctly to delete-image error event', async () => { - mountComponent(); - - findDeleteImage().vm.$emit('error'); - - await wrapper.vm.$nextTick(); - - expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/pages/index_spec.js b/spec/frontend/registry/explorer/pages/index_spec.js deleted file mode 100644 index b5f718b3e61..00000000000 --- a/spec/frontend/registry/explorer/pages/index_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import component from '~/registry/explorer/pages/index.vue'; - -describe('List Page', () => { - let wrapper; - - const findRouterView = () => wrapper.find({ ref: 'router-view' }); - - const mountComponent = () => { - wrapper = shallowMount(component, { - stubs: { - RouterView: true, - }, - }); - }; - - beforeEach(() => { - mountComponent(); - }); - - it('has a router view', () => { - expect(findRouterView().exists()).toBe(true); - }); -}); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js deleted file mode 100644 index e1f24a2b65b..00000000000 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ /dev/null @@ -1,597 +0,0 @@ -import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; -import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; -import DeleteImage from '~/registry/explorer/components/delete_image.vue'; -import CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue'; -import GroupEmptyState from '~/registry/explorer/components/list_page/group_empty_state.vue'; -import ImageList from '~/registry/explorer/components/list_page/image_list.vue'; -import ProjectEmptyState from '~/registry/explorer/components/list_page/project_empty_state.vue'; -import RegistryHeader from '~/registry/explorer/components/list_page/registry_header.vue'; -import { - DELETE_IMAGE_SUCCESS_MESSAGE, - DELETE_IMAGE_ERROR_MESSAGE, - SORT_FIELDS, -} from '~/registry/explorer/constants'; -import deleteContainerRepositoryMutation from '~/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql'; -import getContainerRepositoriesDetails from '~/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql'; -import component from '~/registry/explorer/pages/list.vue'; -import Tracking from '~/tracking'; -import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; -import TitleArea from '~/vue_shared/components/registry/title_area.vue'; - -import { $toast } from '../../shared/mocks'; -import { - graphQLImageListMock, - graphQLImageDeleteMock, - deletedContainerRepository, - graphQLEmptyImageListMock, - graphQLEmptyGroupImageListMock, - pageInfo, - graphQLProjectImageRepositoriesDetailsMock, - dockerCommands, -} from '../mock_data'; -import { GlModal, GlEmptyState } from '../stubs'; - -const localVue = createLocalVue(); - -describe('List Page', () => { - let wrapper; - let apolloProvider; - - const findDeleteModal = () => wrapper.findComponent(GlModal); - const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - - const findCliCommands = () => wrapper.findComponent(CliCommands); - const findProjectEmptyState = () => wrapper.findComponent(ProjectEmptyState); - const findGroupEmptyState = () => wrapper.findComponent(GroupEmptyState); - const findRegistryHeader = () => wrapper.findComponent(RegistryHeader); - - const findDeleteAlert = () => wrapper.findComponent(GlAlert); - const findImageList = () => wrapper.findComponent(ImageList); - const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); - const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); - const findDeleteImage = () => wrapper.findComponent(DeleteImage); - const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); - - const waitForApolloRequestRender = async () => { - jest.runOnlyPendingTimers(); - await waitForPromises(); - await nextTick(); - }; - - const mountComponent = ({ - mocks, - resolver = jest.fn().mockResolvedValue(graphQLImageListMock), - detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), - mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock), - config = { isGroupPage: false }, - query = {}, - } = {}) => { - localVue.use(VueApollo); - - const requestHandlers = [ - [getContainerRepositoriesQuery, resolver], - [getContainerRepositoriesDetails, detailsResolver], - [deleteContainerRepositoryMutation, mutationResolver], - ]; - - apolloProvider = createMockApollo(requestHandlers); - - wrapper = shallowMount(component, { - localVue, - apolloProvider, - stubs: { - GlModal, - GlEmptyState, - GlSprintf, - RegistryHeader, - TitleArea, - DeleteImage, - }, - mocks: { - $toast, - $route: { - name: 'foo', - query, - }, - ...mocks, - }, - provide() { - return { - config, - ...dockerCommands, - }; - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('contains registry header', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findRegistryHeader().exists()).toBe(true); - expect(findRegistryHeader().props()).toMatchObject({ - imagesCount: 2, - metadataLoading: false, - }); - }); - - describe.each([ - { error: 'connectionError', errorName: 'connection error' }, - { error: 'invalidPathError', errorName: 'invalid path error' }, - ])('handling $errorName', ({ error }) => { - const config = { - containersErrorImage: 'foo', - helpPagePath: 'bar', - isGroupPage: false, - }; - config[error] = true; - - it('should show an empty state', () => { - mountComponent({ config }); - - expect(findEmptyState().exists()).toBe(true); - }); - - it('empty state should have an svg-path', () => { - mountComponent({ config }); - - expect(findEmptyState().props('svgPath')).toBe(config.containersErrorImage); - }); - - it('empty state should have a description', () => { - mountComponent({ config }); - - expect(findEmptyState().props('title')).toContain('connection error'); - }); - - it('should not show the loading or default state', () => { - mountComponent({ config }); - - expect(findSkeletonLoader().exists()).toBe(false); - expect(findImageList().exists()).toBe(false); - }); - }); - - describe('isLoading is true', () => { - it('shows the skeleton loader', async () => { - mountComponent(); - - await nextTick(); - - expect(findSkeletonLoader().exists()).toBe(true); - }); - - it('imagesList is not visible', () => { - mountComponent(); - - expect(findImageList().exists()).toBe(false); - }); - - it('cli commands is not visible', () => { - mountComponent(); - - expect(findCliCommands().exists()).toBe(false); - }); - - it('title has the metadataLoading props set to true', async () => { - mountComponent(); - - await nextTick(); - - expect(findRegistryHeader().props('metadataLoading')).toBe(true); - }); - }); - - describe('list is empty', () => { - describe('project page', () => { - const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock); - - it('cli commands is not visible', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findCliCommands().exists()).toBe(false); - }); - - it('project empty state is visible', async () => { - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - expect(findProjectEmptyState().exists()).toBe(true); - }); - }); - - describe('group page', () => { - const resolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock); - - const config = { - isGroupPage: true, - }; - - it('group empty state is visible', async () => { - mountComponent({ resolver, config }); - - await waitForApolloRequestRender(); - - expect(findGroupEmptyState().exists()).toBe(true); - }); - - it('cli commands is not visible', async () => { - mountComponent({ resolver, config }); - - await waitForApolloRequestRender(); - - expect(findCliCommands().exists()).toBe(false); - }); - }); - }); - - describe('list is not empty', () => { - describe('unfiltered state', () => { - it('quick start is visible', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findCliCommands().exists()).toBe(true); - }); - - it('list component is visible', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findImageList().exists()).toBe(true); - }); - - describe('additional metadata', () => { - it('is called on component load', async () => { - const detailsResolver = jest - .fn() - .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); - mountComponent({ detailsResolver }); - - jest.runOnlyPendingTimers(); - await waitForPromises(); - - expect(detailsResolver).toHaveBeenCalled(); - }); - - it('does not block the list ui to show', async () => { - const detailsResolver = jest.fn().mockRejectedValue(); - mountComponent({ detailsResolver }); - - await waitForApolloRequestRender(); - - expect(findImageList().exists()).toBe(true); - }); - - it('loading state is passed to list component', async () => { - // this is a promise that never resolves, to trick apollo to think that this request is still loading - const detailsResolver = jest.fn().mockImplementation(() => new Promise(() => {})); - - mountComponent({ detailsResolver }); - await waitForApolloRequestRender(); - - expect(findImageList().props('metadataLoading')).toBe(true); - }); - }); - - describe('delete image', () => { - const selectImageForDeletion = async () => { - await waitForApolloRequestRender(); - - findImageList().vm.$emit('delete', deletedContainerRepository); - }; - - it('should call deleteItem when confirming deletion', async () => { - const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock); - mountComponent({ mutationResolver }); - - await selectImageForDeletion(); - - findDeleteModal().vm.$emit('primary'); - await waitForApolloRequestRender(); - - expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository); - - const updatedImage = findImageList() - .props('images') - .find((i) => i.id === deletedContainerRepository.id); - - expect(updatedImage.status).toBe(deletedContainerRepository.status); - }); - - it('should show a success alert when delete request is successful', async () => { - mountComponent(); - - await selectImageForDeletion(); - - findDeleteImage().vm.$emit('success'); - await nextTick(); - - const alert = findDeleteAlert(); - expect(alert.exists()).toBe(true); - expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( - DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), - ); - }); - - describe('when delete request fails it shows an alert', () => { - it('user recoverable error', async () => { - mountComponent(); - - await selectImageForDeletion(); - - findDeleteImage().vm.$emit('error'); - await nextTick(); - - const alert = findDeleteAlert(); - expect(alert.exists()).toBe(true); - expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( - DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), - ); - }); - }); - }); - }); - - describe('search and sorting', () => { - const doSearch = async () => { - await waitForApolloRequestRender(); - findRegistrySearch().vm.$emit('filter:changed', [ - { type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } }, - ]); - - findRegistrySearch().vm.$emit('filter:submit'); - - await nextTick(); - }; - - it('has a search box element', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - const registrySearch = findRegistrySearch(); - expect(registrySearch.exists()).toBe(true); - expect(registrySearch.props()).toMatchObject({ - filter: [], - sorting: { orderBy: 'UPDATED', sort: 'desc' }, - sortableFields: SORT_FIELDS, - tokens: [], - }); - }); - - it('performs sorting', async () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); - mountComponent({ resolver }); - - await waitForApolloRequestRender(); - - findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' }); - await nextTick(); - - expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' })); - }); - - it('performs a search', async () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); - mountComponent({ resolver }); - - await doSearch(); - - expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' })); - }); - - it('when search result is empty displays an empty search message', async () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); - const detailsResolver = jest - .fn() - .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); - mountComponent({ resolver, detailsResolver }); - - await waitForApolloRequestRender(); - - resolver.mockResolvedValue(graphQLEmptyImageListMock); - detailsResolver.mockResolvedValue(graphQLEmptyImageListMock); - - await doSearch(); - - expect(findEmptySearchMessage().exists()).toBe(true); - }); - }); - - describe('pagination', () => { - it('prev-page event triggers a fetchMore request', async () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); - const detailsResolver = jest - .fn() - .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); - mountComponent({ resolver, detailsResolver }); - - await waitForApolloRequestRender(); - - findImageList().vm.$emit('prev-page'); - await nextTick(); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ before: pageInfo.startCursor }), - ); - expect(detailsResolver).toHaveBeenCalledWith( - expect.objectContaining({ before: pageInfo.startCursor }), - ); - }); - - it('next-page event triggers a fetchMore request', async () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); - const detailsResolver = jest - .fn() - .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); - mountComponent({ resolver, detailsResolver }); - - await waitForApolloRequestRender(); - - findImageList().vm.$emit('next-page'); - await nextTick(); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ after: pageInfo.endCursor }), - ); - expect(detailsResolver).toHaveBeenCalledWith( - expect.objectContaining({ after: pageInfo.endCursor }), - ); - }); - }); - }); - - describe('modal', () => { - beforeEach(() => { - mountComponent(); - }); - - it('exists', () => { - expect(findDeleteModal().exists()).toBe(true); - }); - - it('contains a description with the path of the item to delete', async () => { - findImageList().vm.$emit('delete', { path: 'foo' }); - await nextTick(); - expect(findDeleteModal().html()).toContain('foo'); - }); - }); - - describe('tracking', () => { - beforeEach(() => { - mountComponent(); - }); - - const testTrackingCall = (action) => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { - label: 'registry_repository_delete', - }); - }; - - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - }); - - it('send an event when delete button is clicked', () => { - findImageList().vm.$emit('delete', {}); - - testTrackingCall('click_button'); - }); - - it('send an event when cancel is pressed on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('cancel'); - testTrackingCall('cancel_delete'); - }); - - it('send an event when the deletion starts', () => { - findDeleteImage().vm.$emit('start'); - testTrackingCall('confirm_delete'); - }); - }); - - describe('url query string handling', () => { - const defaultQueryParams = { - search: [1, 2], - sort: 'asc', - orderBy: 'CREATED', - }; - const queryChangePayload = 'foo'; - - it('query:updated event pushes the new query to the router', async () => { - const push = jest.fn(); - mountComponent({ mocks: { $router: { push } } }); - - await nextTick(); - - findRegistrySearch().vm.$emit('query:changed', queryChangePayload); - - expect(push).toHaveBeenCalledWith({ query: queryChangePayload }); - }); - - it('graphql API call has the variables set from the URL', async () => { - const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); - mountComponent({ query: defaultQueryParams, resolver }); - - await nextTick(); - - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ - name: 1, - sort: 'CREATED_ASC', - }), - ); - }); - - it.each` - sort | orderBy | search | payload - ${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }} - ${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }} - ${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }} - ${undefined} | ${undefined} | ${undefined} | ${{}} - ${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }} - ${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }} - ${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }} - ${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }} - `( - 'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload', - async ({ sort, orderBy, search, payload }) => { - const resolver = jest.fn().mockResolvedValue({ sort, orderBy }); - mountComponent({ query: { sort, orderBy, search }, resolver }); - - await nextTick(); - - expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload)); - }, - ); - }); - - describe('cleanup is on alert', () => { - it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => { - mountComponent({ - config: { - showCleanupPolicyOnAlert: true, - projectPath: 'foo', - isGroupPage: false, - cleanupPoliciesSettingsPath: 'bar', - }, - }); - - await waitForApolloRequestRender(); - - expect(findCleanupAlert().exists()).toBe(true); - expect(findCleanupAlert().props()).toMatchObject({ - projectPath: 'foo', - cleanupPoliciesSettingsPath: 'bar', - }); - }); - - it('is hidden when showCleanupPolicyOnAlert is false', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findCleanupAlert().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js deleted file mode 100644 index 4f65e73d3fa..00000000000 --- a/spec/frontend/registry/explorer/stubs.js +++ /dev/null @@ -1,45 +0,0 @@ -import { - GlModal as RealGlModal, - GlEmptyState as RealGlEmptyState, - GlSkeletonLoader as RealGlSkeletonLoader, - GlDropdown as RealGlDropdown, -} from '@gitlab/ui'; -import { RouterLinkStub } from '@vue/test-utils'; -import { stubComponent } from 'helpers/stub_component'; -import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue'; -import RealListItem from '~/vue_shared/components/registry/list_item.vue'; - -export const GlModal = stubComponent(RealGlModal, { - template: '
    ', - methods: { - show: jest.fn(), - }, -}); - -export const GlEmptyState = stubComponent(RealGlEmptyState, { - template: '
    ', -}); - -export const RouterLink = RouterLinkStub; - -export const DeleteModal = stubComponent(RealDeleteModal, { - methods: { - show: jest.fn(), - }, -}); - -export const GlSkeletonLoader = stubComponent(RealGlSkeletonLoader); - -export const ListItem = { - ...RealListItem, - data() { - return { - detailsSlots: [], - isDetailsShown: true, - }; - }, -}; - -export const GlDropdown = stubComponent(RealGlDropdown, { - template: '
    ', -}); diff --git a/spec/frontend/registry/shared/mocks.js b/spec/frontend/registry/shared/mocks.js deleted file mode 100644 index fdef38b6f10..00000000000 --- a/spec/frontend/registry/shared/mocks.js +++ /dev/null @@ -1,3 +0,0 @@ -export const $toast = { - show: jest.fn(), -}; diff --git a/spec/frontend/registry/shared/stubs.js b/spec/frontend/registry/shared/stubs.js deleted file mode 100644 index ad41eb42df4..00000000000 --- a/spec/frontend/registry/shared/stubs.js +++ /dev/null @@ -1,31 +0,0 @@ -export const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '' }; -export const GlCard = { - name: 'gl-card-stub', - template: ` -
    - - - -
    -`, -}; - -export const GlFormGroup = { - name: 'gl-form-group-stub', - props: ['state'], - template: ` -
    - - - -
    `, -}; - -export const GlFormSelect = { - name: 'gl-form-select-stub', - props: ['disabled', 'value'], - template: ` -
    - -
    `, -}; diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js index 67f62815720..486fb699275 100644 --- a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js +++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js @@ -66,8 +66,8 @@ describe('RelatedMergeRequests', () => { describe('template', () => { it('should render related merge request items', () => { - expect(wrapper.find('.js-items-count').text()).toEqual('2'); - expect(wrapper.findAll(RelatedIssuableItem).length).toEqual(2); + expect(wrapper.find('[data-testid="count"]').text()).toBe('2'); + expect(wrapper.findAll(RelatedIssuableItem)).toHaveLength(2); const props = wrapper.findAll(RelatedIssuableItem).at(1).props(); const data = mockData[1]; diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index 114e46ce64b..0f416e46dba 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -1,6 +1,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; +import { __ } from '~/locale'; import TagFieldNew from '~/releases/components/tag_field_new.vue'; import createStore from '~/releases/stores'; import createEditNewModule from '~/releases/stores/modules/edit_new'; @@ -84,7 +85,8 @@ describe('releases/components/tag_field_new', () => { beforeEach(() => createComponent()); it('renders a label', () => { - expect(findTagNameFormGroup().attributes().label).toBe('Tag name'); + expect(findTagNameFormGroup().attributes().label).toBe(__('Tag name')); + expect(findTagNameFormGroup().props().labelDescription).toBe(__('*Required')); }); describe('when the user selects a new tag name', () => { diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 59db537282b..d40e97bf5a3 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -1,5 +1,5 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; +import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; @@ -19,6 +19,15 @@ import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue'; import blobInfoQuery from '~/repository/queries/blob_info.query.graphql'; import { redirectTo } from '~/lib/utils/url_utility'; import { isLoggedIn } from '~/lib/utils/common_utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + simpleViewerMock, + richViewerMock, + projectMock, + userPermissionsMock, + propsMock, + refMock, +} from '../mock_data'; jest.mock('~/repository/components/blob_viewers'); jest.mock('~/lib/utils/url_utility'); @@ -27,151 +36,63 @@ jest.mock('~/lib/utils/common_utils'); let wrapper; let mockResolver; -const simpleMockData = { - name: 'some_file.js', - size: 123, - rawSize: 123, - rawTextBlob: 'raw content', - type: 'text', - fileType: 'text', - tooLarge: false, - path: 'some_file.js', - webPath: 'some_file.js', - editBlobPath: 'some_file.js/edit', - ideEditPath: 'some_file.js/ide/edit', - forkAndEditPath: 'some_file.js/fork/edit', - ideForkAndEditPath: 'some_file.js/fork/ide', - canModifyBlob: true, - storedExternally: false, - rawPath: 'some_file.js', - externalStorageUrl: 'some_file.js', - replacePath: 'some_file.js/replace', - deletePath: 'some_file.js/delete', - simpleViewer: { - fileType: 'text', - tooLarge: false, - type: 'simple', - renderError: null, - }, - richViewer: null, -}; -const richMockData = { - ...simpleMockData, - richViewer: { - fileType: 'markup', - tooLarge: false, - type: 'rich', - renderError: null, - }, -}; - -const projectMockData = { - userPermissions: { - pushCode: true, - downloadCode: true, - createMergeRequestIn: true, - forkProject: true, - }, - repository: { - empty: false, - }, -}; - const localVue = createLocalVue(); const mockAxios = new MockAdapter(axios); -const createComponentWithApollo = (mockData = {}, inject = {}) => { +const createComponent = async (mockData = {}, mountFn = shallowMount) => { localVue.use(VueApollo); - const defaultPushCode = projectMockData.userPermissions.pushCode; - const defaultDownloadCode = projectMockData.userPermissions.downloadCode; - const defaultEmptyRepo = projectMockData.repository.empty; const { - blobs, - emptyRepo = defaultEmptyRepo, - canPushCode = defaultPushCode, - canDownloadCode = defaultDownloadCode, - createMergeRequestIn = projectMockData.userPermissions.createMergeRequestIn, - forkProject = projectMockData.userPermissions.forkProject, - pathLocks = [], + blob = simpleViewerMock, + empty = projectMock.repository.empty, + pushCode = userPermissionsMock.pushCode, + forkProject = userPermissionsMock.forkProject, + downloadCode = userPermissionsMock.downloadCode, + createMergeRequestIn = userPermissionsMock.createMergeRequestIn, + isBinary, + inject = {}, } = mockData; - mockResolver = jest.fn().mockResolvedValue({ - data: { - project: { - id: '1234', - userPermissions: { - pushCode: canPushCode, - downloadCode: canDownloadCode, - createMergeRequestIn, - forkProject, - }, - pathLocks: { - nodes: pathLocks, - }, - repository: { - empty: emptyRepo, - blobs: { - nodes: [blobs], - }, - }, - }, + const project = { + ...projectMock, + userPermissions: { + pushCode, + forkProject, + downloadCode, + createMergeRequestIn, + }, + repository: { + empty, + blobs: { nodes: [blob] }, }, + }; + + mockResolver = jest.fn().mockResolvedValue({ + data: { isBinary, project }, }); const fakeApollo = createMockApollo([[blobInfoQuery, mockResolver]]); - wrapper = shallowMount(BlobContentViewer, { - localVue, - apolloProvider: fakeApollo, - propsData: { - path: 'some_file.js', - projectPath: 'some/path', - }, - mixins: [ - { - data: () => ({ ref: 'default-ref' }), - }, - ], - provide: { - ...inject, - }, - }); -}; + wrapper = extendedWrapper( + mountFn(BlobContentViewer, { + localVue, + apolloProvider: fakeApollo, + propsData: propsMock, + mixins: [{ data: () => ({ ref: refMock }) }], + provide: { ...inject }, + }), + ); -const createFactory = (mountFn) => ( - { props = {}, mockData = {}, stubs = {} } = {}, - loading = false, -) => { - wrapper = mountFn(BlobContentViewer, { - propsData: { - path: 'some_file.js', - projectPath: 'some/path', - ...props, - }, - mocks: { - $apollo: { - queries: { - project: { - loading, - refetch: jest.fn(), - }, - }, - }, - }, - stubs, - }); + wrapper.setData({ project, isBinary }); - wrapper.setData(mockData); + await waitForPromises(); }; -const factory = createFactory(shallowMount); -const fullFactory = createFactory(mount); - describe('Blob content viewer component', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findBlobHeader = () => wrapper.findComponent(BlobHeader); const findBlobEdit = () => wrapper.findComponent(BlobEdit); + const findPipelineEditor = () => wrapper.findByTestId('pipeline-editor'); const findBlobContent = () => wrapper.findComponent(BlobContent); const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion); @@ -187,25 +108,24 @@ describe('Blob content viewer component', () => { }); it('renders a GlLoadingIcon component', () => { - factory({ mockData: { blobInfo: simpleMockData } }, true); + createComponent(); expect(findLoadingIcon().exists()).toBe(true); }); describe('simple viewer', () => { - beforeEach(() => { - factory({ mockData: { blobInfo: simpleMockData } }); - }); + it('renders a BlobHeader component', async () => { + await createComponent(); - it('renders a BlobHeader component', () => { expect(findBlobHeader().props('activeViewerType')).toEqual('simple'); expect(findBlobHeader().props('hasRenderError')).toEqual(false); expect(findBlobHeader().props('hideViewerSwitcher')).toEqual(true); - expect(findBlobHeader().props('blob')).toEqual(simpleMockData); + expect(findBlobHeader().props('blob')).toEqual(simpleViewerMock); }); - it('renders a BlobContent component', () => { - expect(findBlobContent().props('loading')).toEqual(false); + it('renders a BlobContent component', async () => { + await createComponent(); + expect(findBlobContent().props('isRawContent')).toBe(true); expect(findBlobContent().props('activeViewer')).toEqual({ fileType: 'text', @@ -217,8 +137,7 @@ describe('Blob content viewer component', () => { describe('legacy viewers', () => { it('loads a legacy viewer when a viewer component is not available', async () => { - createComponentWithApollo({ blobs: { ...simpleMockData, fileType: 'unknown' } }); - await waitForPromises(); + await createComponent({ blob: { ...simpleViewerMock, fileType: 'unknown' } }); expect(mockAxios.history.get).toHaveLength(1); expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple'); @@ -227,21 +146,18 @@ describe('Blob content viewer component', () => { }); describe('rich viewer', () => { - beforeEach(() => { - factory({ - mockData: { blobInfo: richMockData, activeViewerType: 'rich' }, - }); - }); + it('renders a BlobHeader component', async () => { + await createComponent({ blob: richViewerMock }); - it('renders a BlobHeader component', () => { expect(findBlobHeader().props('activeViewerType')).toEqual('rich'); expect(findBlobHeader().props('hasRenderError')).toEqual(false); expect(findBlobHeader().props('hideViewerSwitcher')).toEqual(false); - expect(findBlobHeader().props('blob')).toEqual(richMockData); + expect(findBlobHeader().props('blob')).toEqual(richViewerMock); }); - it('renders a BlobContent component', () => { - expect(findBlobContent().props('loading')).toEqual(false); + it('renders a BlobContent component', async () => { + await createComponent({ blob: richViewerMock }); + expect(findBlobContent().props('isRawContent')).toBe(true); expect(findBlobContent().props('activeViewer')).toEqual({ fileType: 'markup', @@ -252,6 +168,8 @@ describe('Blob content viewer component', () => { }); it('updates viewer type when viewer changed is clicked', async () => { + await createComponent({ blob: richViewerMock }); + expect(findBlobContent().props('activeViewer')).toEqual( expect.objectContaining({ type: 'rich', @@ -273,8 +191,7 @@ describe('Blob content viewer component', () => { describe('legacy viewers', () => { it('loads a legacy viewer when a viewer component is not available', async () => { - createComponentWithApollo({ blobs: { ...richMockData, fileType: 'unknown' } }); - await waitForPromises(); + await createComponent({ blob: { ...richViewerMock, fileType: 'unknown' } }); expect(mockAxios.history.get).toHaveLength(1); expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=rich'); @@ -287,9 +204,9 @@ describe('Blob content viewer component', () => { viewerProps.mockRestore(); }); - it('does not render a BlobContent component if a Blob viewer is available', () => { - loadViewer.mockReturnValueOnce(() => true); - factory({ mockData: { blobInfo: richMockData } }); + it('does not render a BlobContent component if a Blob viewer is available', async () => { + loadViewer.mockReturnValue(() => true); + await createComponent({ blob: richViewerMock }); expect(findBlobContent().exists()).toBe(false); }); @@ -305,15 +222,13 @@ describe('Blob content viewer component', () => { loadViewer.mockReturnValue(loadViewerReturnValue); viewerProps.mockReturnValue(viewerPropsReturnValue); - factory({ - mockData: { - blobInfo: { - ...simpleMockData, - fileType: null, - simpleViewer: { - ...simpleMockData.simpleViewer, - fileType: viewer, - }, + createComponent({ + blob: { + ...simpleViewerMock, + fileType: 'null', + simpleViewer: { + ...simpleViewerMock.simpleViewer, + fileType: viewer, }, }, }); @@ -327,18 +242,10 @@ describe('Blob content viewer component', () => { }); describe('BlobHeader action slot', () => { - const { ideEditPath, editBlobPath } = simpleMockData; + const { ideEditPath, editBlobPath } = simpleViewerMock; it('renders BlobHeaderEdit buttons in simple viewer', async () => { - fullFactory({ - mockData: { blobInfo: simpleMockData }, - stubs: { - BlobContent: true, - BlobReplace: true, - }, - }); - - await nextTick(); + await createComponent({ inject: { BlobContent: true, BlobReplace: true } }, mount); expect(findBlobEdit().props()).toMatchObject({ editPath: editBlobPath, @@ -348,15 +255,7 @@ describe('Blob content viewer component', () => { }); it('renders BlobHeaderEdit button in rich viewer', async () => { - fullFactory({ - mockData: { blobInfo: richMockData }, - stubs: { - BlobContent: true, - BlobReplace: true, - }, - }); - - await nextTick(); + await createComponent({ blob: richViewerMock }, mount); expect(findBlobEdit().props()).toMatchObject({ editPath: editBlobPath, @@ -366,15 +265,7 @@ describe('Blob content viewer component', () => { }); it('renders BlobHeaderEdit button for binary files', async () => { - fullFactory({ - mockData: { blobInfo: richMockData, isBinary: true }, - stubs: { - BlobContent: true, - BlobReplace: true, - }, - }); - - await nextTick(); + await createComponent({ blob: richViewerMock, isBinary: true }, mount); expect(findBlobEdit().props()).toMatchObject({ editPath: editBlobPath, @@ -383,42 +274,36 @@ describe('Blob content viewer component', () => { }); }); - describe('blob header binary file', () => { - it.each([richMockData, { simpleViewer: { fileType: 'download' } }])( - 'passes the correct isBinary value when viewing a binary file', - async (blobInfo) => { - fullFactory({ - mockData: { - blobInfo, - isBinary: true, - }, - stubs: { BlobContent: true, BlobReplace: true }, - }); + it('renders Pipeline Editor button for .gitlab-ci files', async () => { + const pipelineEditorPath = 'some/path/.gitlab-ce'; + const blob = { ...simpleViewerMock, pipelineEditorPath }; + await createComponent({ blob, inject: { BlobContent: true, BlobReplace: true } }, mount); - await nextTick(); + expect(findPipelineEditor().exists()).toBe(true); + expect(findPipelineEditor().attributes('href')).toBe(pipelineEditorPath); + }); - expect(findBlobHeader().props('isBinary')).toBe(true); - }, - ); + describe('blob header binary file', () => { + it('passes the correct isBinary value when viewing a binary file', async () => { + await createComponent({ blob: richViewerMock, isBinary: true }); + + expect(findBlobHeader().props('isBinary')).toBe(true); + }); it('passes the correct header props when viewing a non-text file', async () => { - fullFactory({ - mockData: { - blobInfo: { - ...simpleMockData, + await createComponent( + { + blob: { + ...simpleViewerMock, simpleViewer: { - ...simpleMockData.simpleViewer, + ...simpleViewerMock.simpleViewer, fileType: 'image', }, }, + isBinary: true, }, - stubs: { - BlobContent: true, - BlobReplace: true, - }, - }); - - await nextTick(); + mount, + ); expect(findBlobHeader().props('hideViewerSwitcher')).toBe(true); expect(findBlobHeader().props('isBinary')).toBe(true); @@ -427,27 +312,16 @@ describe('Blob content viewer component', () => { }); describe('BlobButtonGroup', () => { - const { name, path, replacePath, webPath } = simpleMockData; + const { name, path, replacePath, webPath } = simpleViewerMock; const { userPermissions: { pushCode, downloadCode }, repository: { empty }, - } = projectMockData; + } = projectMock; it('renders component', async () => { window.gon.current_user_id = 1; - fullFactory({ - mockData: { - blobInfo: simpleMockData, - project: { userPermissions: { pushCode, downloadCode }, repository: { empty } }, - }, - stubs: { - BlobContent: true, - BlobButtonGroup: true, - }, - }); - - await nextTick(); + await createComponent({ pushCode, downloadCode, empty }, mount); expect(findBlobButtonGroup().props()).toMatchObject({ name, @@ -467,21 +341,14 @@ describe('Blob content viewer component', () => { ${false} | ${true} | ${false} ${true} | ${false} | ${false} `('passes the correct lock states', async ({ canPushCode, canDownloadCode, canLock }) => { - fullFactory({ - mockData: { - blobInfo: simpleMockData, - project: { - userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode }, - repository: { empty }, - }, + await createComponent( + { + pushCode: canPushCode, + downloadCode: canDownloadCode, + empty, }, - stubs: { - BlobContent: true, - BlobButtonGroup: true, - }, - }); - - await nextTick(); + mount, + ); expect(findBlobButtonGroup().props('canLock')).toBe(canLock); }); @@ -489,15 +356,7 @@ describe('Blob content viewer component', () => { it('does not render if not logged in', async () => { isLoggedIn.mockReturnValueOnce(false); - fullFactory({ - mockData: { blobInfo: simpleMockData }, - stubs: { - BlobContent: true, - BlobReplace: true, - }, - }); - - await nextTick(); + await createComponent(); expect(findBlobButtonGroup().exists()).toBe(false); }); @@ -506,10 +365,7 @@ describe('Blob content viewer component', () => { describe('blob info query', () => { it('is called with originalBranch value if the prop has a value', async () => { - const inject = { originalBranch: 'some-branch' }; - createComponentWithApollo({ blobs: simpleMockData }, inject); - - await waitForPromises(); + await createComponent({ inject: { originalBranch: 'some-branch' } }); expect(mockResolver).toHaveBeenCalledWith( expect.objectContaining({ @@ -519,10 +375,7 @@ describe('Blob content viewer component', () => { }); it('is called with ref value if the originalBranch prop has no value', async () => { - const inject = { originalBranch: null }; - createComponentWithApollo({ blobs: simpleMockData }, inject); - - await waitForPromises(); + await createComponent(); expect(mockResolver).toHaveBeenCalledWith( expect.objectContaining({ @@ -533,24 +386,16 @@ describe('Blob content viewer component', () => { }); describe('edit blob', () => { - beforeEach(() => { - fullFactory({ - mockData: { blobInfo: simpleMockData }, - stubs: { - BlobContent: true, - BlobReplace: true, - }, - }); - }); + beforeEach(() => createComponent({}, mount)); it('simple edit redirects to the simple editor', () => { findBlobEdit().vm.$emit('edit', 'simple'); - expect(redirectTo).toHaveBeenCalledWith(simpleMockData.editBlobPath); + expect(redirectTo).toHaveBeenCalledWith(simpleViewerMock.editBlobPath); }); it('IDE edit redirects to the IDE editor', () => { findBlobEdit().vm.$emit('edit', 'ide'); - expect(redirectTo).toHaveBeenCalledWith(simpleMockData.ideEditPath); + expect(redirectTo).toHaveBeenCalledWith(simpleViewerMock.ideEditPath); }); it.each` @@ -569,16 +414,14 @@ describe('Blob content viewer component', () => { showForkSuggestion, }) => { isLoggedIn.mockReturnValueOnce(loggedIn); - fullFactory({ - mockData: { - blobInfo: { ...simpleMockData, canModifyBlob }, - project: { userPermissions: { createMergeRequestIn, forkProject } }, + await createComponent( + { + blob: { ...simpleViewerMock, canModifyBlob }, + createMergeRequestIn, + forkProject, }, - stubs: { - BlobContent: true, - BlobButtonGroup: true, - }, - }); + mount, + ); findBlobEdit().vm.$emit('edit', 'simple'); await nextTick(); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index 08a6583b60c..36847107558 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -6,11 +6,9 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; -import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; -jest.mock('~/projects/upload_file_experiment_tracking'); jest.mock('~/flash'); jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), @@ -162,10 +160,6 @@ describe('UploadBlobModal', () => { await waitForPromises(); }); - it('tracks the click_upload_modal_trigger event when opening the modal', () => { - expect(trackFileUploadEvent).toHaveBeenCalledWith('click_upload_modal_form_submit'); - }); - it('redirects to the uploaded file', () => { expect(visitUrl).toHaveBeenCalled(); }); @@ -185,10 +179,6 @@ describe('UploadBlobModal', () => { await waitForPromises(); }); - it('does not track an event', () => { - expect(trackFileUploadEvent).not.toHaveBeenCalled(); - }); - it('creates a flash error', () => { expect(createFlash).toHaveBeenCalledWith({ message: 'Error uploading file. Please try again.', diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js new file mode 100644 index 00000000000..adf5991ac3c --- /dev/null +++ b/spec/frontend/repository/mock_data.js @@ -0,0 +1,57 @@ +export const simpleViewerMock = { + name: 'some_file.js', + size: 123, + rawSize: 123, + rawTextBlob: 'raw content', + fileType: 'text', + path: 'some_file.js', + webPath: 'some_file.js', + editBlobPath: 'some_file.js/edit', + ideEditPath: 'some_file.js/ide/edit', + forkAndEditPath: 'some_file.js/fork/edit', + ideForkAndEditPath: 'some_file.js/fork/ide', + canModifyBlob: true, + storedExternally: false, + rawPath: 'some_file.js', + replacePath: 'some_file.js/replace', + pipelineEditorPath: '', + simpleViewer: { + fileType: 'text', + tooLarge: false, + type: 'simple', + renderError: null, + }, + richViewer: null, +}; + +export const richViewerMock = { + ...simpleViewerMock, + richViewer: { + fileType: 'markup', + tooLarge: false, + type: 'rich', + renderError: null, + }, +}; + +export const userPermissionsMock = { + pushCode: true, + forkProject: true, + downloadCode: true, + createMergeRequestIn: true, +}; + +export const projectMock = { + id: '1234', + userPermissions: userPermissionsMock, + pathLocks: { + nodes: [], + }, + repository: { + empty: false, + }, +}; + +export const propsMock = { path: 'some_file.js', projectPath: 'some/path' }; + +export const refMock = 'default-ref'; diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 33e9c122080..7eda9aa2850 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -10,9 +10,10 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; +import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; -import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; import { @@ -22,7 +23,6 @@ import { DEFAULT_SORT, INSTANCE_TYPE, PARAM_KEY_STATUS, - PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG, STATUS_ACTIVE, RUNNER_PAGE_SIZE, @@ -34,7 +34,11 @@ import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered import { runnersData, runnersDataPaginated } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; -const mockActiveRunnersCount = 2; +const mockActiveRunnersCount = '2'; +const mockAllRunnersCount = '6'; +const mockInstanceRunnersCount = '3'; +const mockGroupRunnersCount = '2'; +const mockProjectRunnersCount = '1'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -50,7 +54,8 @@ describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; - const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationPrev = () => @@ -66,8 +71,12 @@ describe('AdminRunnersApp', () => { localVue, apolloProvider: createMockApollo(handlers), propsData: { - activeRunnersCount: mockActiveRunnersCount, registrationToken: mockRegistrationToken, + activeRunnersCount: mockActiveRunnersCount, + allRunnersCount: mockAllRunnersCount, + instanceRunnersCount: mockInstanceRunnersCount, + groupRunnersCount: mockGroupRunnersCount, + projectRunnersCount: mockProjectRunnersCount, ...props, }, }); @@ -86,8 +95,19 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); + it('shows the runner tabs with a runner count', async () => { + createComponent({ mountFn: mount }); + + await waitForPromises(); + + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + `All ${mockAllRunnersCount} Instance ${mockInstanceRunnersCount} Group ${mockGroupRunnersCount} Project ${mockProjectRunnersCount}`, + ); + }); + it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE); }); it('shows the runners list', () => { @@ -125,10 +145,6 @@ describe('AdminRunnersApp', () => { type: PARAM_KEY_STATUS, options: expect.any(Array), }), - expect.objectContaining({ - type: PARAM_KEY_RUNNER_TYPE, - options: expect.any(Array), - }), expect.objectContaining({ type: PARAM_KEY_TAG, recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, @@ -154,9 +170,9 @@ describe('AdminRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + runnerType: INSTANCE_TYPE, filters: [ { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, - { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, { type: 'tag', value: { data: 'tag1', operator: '=' } }, ], sort: 'CREATED_DESC', @@ -178,6 +194,7 @@ describe('AdminRunnersApp', () => { describe('when a filter is selected by the user', () => { beforeEach(() => { findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], sort: CREATED_ASC, }); diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 5aa3879ac3e..2874bdbe280 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -8,12 +8,11 @@ import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue' import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; +import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; -import { runnersData, runnerData } from '../../mock_data'; +import { runnersData } from '../../mock_data'; const mockRunner = runnersData.data.runners.nodes[0]; -const mockRunnerDetails = runnerData.data.runner; const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value; @@ -27,7 +26,7 @@ jest.mock('~/runner/sentry_utils'); describe('RunnerTypeCell', () => { let wrapper; const runnerDeleteMutationHandler = jest.fn(); - const runnerUpdateMutationHandler = jest.fn(); + const runnerActionsUpdateMutationHandler = jest.fn(); const findEditBtn = () => wrapper.findByTestId('edit-runner'); const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); @@ -46,7 +45,7 @@ describe('RunnerTypeCell', () => { localVue, apolloProvider: createMockApollo([ [runnerDeleteMutation, runnerDeleteMutationHandler], - [runnerUpdateMutation, runnerUpdateMutationHandler], + [runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler], ]), ...options, }), @@ -62,10 +61,10 @@ describe('RunnerTypeCell', () => { }, }); - runnerUpdateMutationHandler.mockResolvedValue({ + runnerActionsUpdateMutationHandler.mockResolvedValue({ data: { runnerUpdate: { - runner: mockRunnerDetails, + runner: mockRunner, errors: [], }, }, @@ -74,7 +73,7 @@ describe('RunnerTypeCell', () => { afterEach(() => { runnerDeleteMutationHandler.mockReset(); - runnerUpdateMutationHandler.mockReset(); + runnerActionsUpdateMutationHandler.mockReset(); wrapper.destroy(); }); @@ -116,12 +115,12 @@ describe('RunnerTypeCell', () => { describe(`When clicking on the ${icon} button`, () => { it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => { - expect(runnerUpdateMutationHandler).toHaveBeenCalledTimes(0); + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0); await findToggleActiveBtn().vm.$emit('click'); - expect(runnerUpdateMutationHandler).toHaveBeenCalledTimes(1); - expect(runnerUpdateMutationHandler).toHaveBeenCalledWith({ + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1); + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({ input: { id: mockRunner.id, active: newActiveValue, @@ -145,7 +144,7 @@ describe('RunnerTypeCell', () => { const mockErrorMsg = 'Update error!'; beforeEach(async () => { - runnerUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); await findToggleActiveBtn().vm.$emit('click'); }); @@ -167,10 +166,10 @@ describe('RunnerTypeCell', () => { const mockErrorMsg2 = 'User not allowed!'; beforeEach(async () => { - runnerUpdateMutationHandler.mockResolvedValue({ + runnerActionsUpdateMutationHandler.mockResolvedValue({ data: { runnerUpdate: { - runner: runnerData.data.runner, + runner: mockRunner, errors: [mockErrorMsg, mockErrorMsg2], }, }, diff --git a/spec/frontend/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/runner/components/cells/runner_status_cell_spec.js new file mode 100644 index 00000000000..20a1cdf7236 --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_status_cell_spec.js @@ -0,0 +1,69 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerStatusCell from '~/runner/components/cells/runner_status_cell.vue'; +import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE } from '~/runner/constants'; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findBadgeAt = (i) => wrapper.findAllComponents(GlBadge).at(i); + + const createComponent = ({ runner = {} } = {}) => { + wrapper = mount(RunnerStatusCell, { + propsData: { + runner: { + runnerType: INSTANCE_TYPE, + active: true, + status: STATUS_ONLINE, + ...runner, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays online status', () => { + createComponent(); + + expect(wrapper.text()).toMatchInterpolatedText('online'); + expect(findBadgeAt(0).text()).toBe('online'); + }); + + it('Displays offline status', () => { + createComponent({ + runner: { + status: STATUS_OFFLINE, + }, + }); + + expect(wrapper.text()).toMatchInterpolatedText('offline'); + expect(findBadgeAt(0).text()).toBe('offline'); + }); + + it('Displays paused status', () => { + createComponent({ + runner: { + active: false, + status: STATUS_ONLINE, + }, + }); + + expect(wrapper.text()).toMatchInterpolatedText('online paused'); + + expect(findBadgeAt(0).text()).toBe('online'); + expect(findBadgeAt(1).text()).toBe('paused'); + }); + + it('Is empty when data is missing', () => { + createComponent({ + runner: { + status: null, + }, + }); + + expect(wrapper.text()).toBe(''); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js index 1c9282e0acd..b6d957d27ea 100644 --- a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js @@ -1,5 +1,6 @@ -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import RunnerSummaryCell from '~/runner/components/cells/runner_summary_cell.vue'; +import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants'; const mockId = '1'; const mockShortSha = '2P6oDVDm'; @@ -8,13 +9,17 @@ const mockDescription = 'runner-1'; describe('RunnerTypeCell', () => { let wrapper; - const createComponent = (options) => { - wrapper = mount(RunnerSummaryCell, { + const findLockIcon = () => wrapper.findByTestId('lock-icon'); + + const createComponent = (runner, options) => { + wrapper = mountExtended(RunnerSummaryCell, { propsData: { runner: { id: `gid://gitlab/Ci::Runner/${mockId}`, shortSha: mockShortSha, description: mockDescription, + runnerType: INSTANCE_TYPE, + ...runner, }, }, ...options, @@ -33,6 +38,23 @@ describe('RunnerTypeCell', () => { expect(wrapper.text()).toContain(`#${mockId} (${mockShortSha})`); }); + it('Displays the runner type', () => { + expect(wrapper.text()).toContain('shared'); + }); + + it('Does not display the locked icon', () => { + expect(findLockIcon().exists()).toBe(false); + }); + + it('Displays the locked icon for locked runners', () => { + createComponent({ + runnerType: PROJECT_TYPE, + locked: true, + }); + + expect(findLockIcon().exists()).toBe(true); + }); + it('Displays the runner description', () => { expect(wrapper.text()).toContain(mockDescription); }); @@ -40,11 +62,14 @@ describe('RunnerTypeCell', () => { it('Displays a custom slot', () => { const slotContent = 'My custom runner summary'; - createComponent({ - slots: { - 'runner-name': slotContent, + createComponent( + {}, + { + slots: { + 'runner-name': slotContent, + }, }, - }); + ); expect(wrapper.text()).toContain(slotContent); }); diff --git a/spec/frontend/runner/components/cells/runner_type_cell_spec.js b/spec/frontend/runner/components/cells/runner_type_cell_spec.js deleted file mode 100644 index 48958a282fc..00000000000 --- a/spec/frontend/runner/components/cells/runner_type_cell_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue'; -import { INSTANCE_TYPE } from '~/runner/constants'; - -describe('RunnerTypeCell', () => { - let wrapper; - - const findBadges = () => wrapper.findAllComponents(GlBadge); - - const createComponent = ({ runner = {} } = {}) => { - wrapper = mount(RunnerTypeCell, { - propsData: { - runner: { - runnerType: INSTANCE_TYPE, - active: true, - locked: false, - ...runner, - }, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('Displays the runner type', () => { - createComponent(); - - expect(findBadges()).toHaveLength(1); - expect(findBadges().at(0).text()).toBe('shared'); - }); - - it('Displays locked and paused states', () => { - createComponent({ - runner: { - active: false, - locked: true, - }, - }); - - expect(findBadges()).toHaveLength(3); - expect(findBadges().at(0).text()).toBe('shared'); - expect(findBadges().at(1).text()).toBe('locked'); - expect(findBadges().at(2).text()).toBe('paused'); - }); -}); diff --git a/spec/frontend/runner/components/helpers/masked_value_spec.js b/spec/frontend/runner/components/helpers/masked_value_spec.js deleted file mode 100644 index f87315057ec..00000000000 --- a/spec/frontend/runner/components/helpers/masked_value_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MaskedValue from '~/runner/components/helpers/masked_value.vue'; - -const mockSecret = '01234567890'; -const mockMasked = '***********'; - -describe('MaskedValue', () => { - let wrapper; - - const findButton = () => wrapper.findComponent(GlButton); - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(MaskedValue, { - propsData: { - value: mockSecret, - ...props, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('Displays masked value by default', () => { - expect(wrapper.text()).toBe(mockMasked); - }); - - describe('When the icon is clicked', () => { - beforeEach(() => { - findButton().vm.$emit('click'); - }); - - it('Displays the actual value', () => { - expect(wrapper.text()).toBe(mockSecret); - expect(wrapper.text()).not.toBe(mockMasked); - }); - - it('When user clicks again, displays masked value', async () => { - await findButton().vm.$emit('click'); - - expect(wrapper.text()).toBe(mockMasked); - expect(wrapper.text()).not.toBe(mockSecret); - }); - }); -}); diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js new file mode 100644 index 00000000000..d18d2bec18e --- /dev/null +++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js @@ -0,0 +1,169 @@ +import { GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount, createWrapper } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; +import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; + +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; + +import { + mockGraphqlRunnerPlatforms, + mockGraphqlInstructions, +} from 'jest/vue_shared/components/runner_instructions/mock_data'; + +const mockToken = '0123456789'; +const maskToken = '**********'; + +describe('RegistrationDropdown', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + + const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); + const findTokenResetDropdownItem = () => + wrapper.findComponent(RegistrationTokenResetDropdownItem); + + const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); + + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { + wrapper = extendedWrapper( + mountFn(RegistrationDropdown, { + propsData: { + registrationToken: mockToken, + type: INSTANCE_TYPE, + ...props, + }, + ...options, + }), + ); + }; + + it.each` + type | text + ${INSTANCE_TYPE} | ${'Register an instance runner'} + ${GROUP_TYPE} | ${'Register a group runner'} + ${PROJECT_TYPE} | ${'Register a project runner'} + `('Dropdown text for type $type is "$text"', () => { + createComponent({ props: { type: INSTANCE_TYPE } }, mount); + + expect(wrapper.text()).toContain('Register an instance runner'); + }); + + it('Passes attributes to the dropdown component', () => { + createComponent({ attrs: { right: true } }); + + expect(findDropdown().attributes()).toMatchObject({ right: 'true' }); + }); + + describe('Instructions dropdown item', () => { + it('Displays "Show runner" dropdown item', () => { + createComponent(); + + expect(findRegistrationInstructionsDropdownItem().text()).toBe( + 'Show runner installation and registration instructions', + ); + }); + + describe('When the dropdown item is clicked', () => { + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const requestHandlers = [ + [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], + [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + ]; + + const findModalInBody = () => + createWrapper(document.body).find('[data-testid="runner-instructions-modal"]'); + + beforeEach(() => { + createComponent( + { + localVue, + // Mock load modal contents from API + apolloProvider: createMockApollo(requestHandlers), + // Use `attachTo` to find the modal + attachTo: document.body, + }, + mount, + ); + + findRegistrationInstructionsDropdownItem().trigger('click'); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('opens the modal with contents', () => { + const modalText = findModalInBody() + .text() + .replace(/[\n\t\s]+/g, ' '); + + expect(modalText).toContain('Install a runner'); + + // Environment selector + expect(modalText).toContain('Environment'); + expect(modalText).toContain('Linux macOS Windows Docker Kubernetes'); + + // Architecture selector + expect(modalText).toContain('Architecture'); + expect(modalText).toContain('amd64 amd64 386 arm arm64'); + + expect(modalText).toContain('Download and install binary'); + }); + }); + }); + + describe('Registration token', () => { + it('Displays dropdown form for the registration token', () => { + createComponent(); + + expect(findTokenDropdownItem().exists()).toBe(true); + }); + + it('Displays masked value by default', () => { + createComponent({}, mount); + + expect(findTokenDropdownItem().text()).toMatchInterpolatedText( + `Registration token ${maskToken}`, + ); + }); + }); + + describe('Reset token item', () => { + it('Displays registration token reset item', () => { + createComponent(); + + expect(findTokenResetDropdownItem().exists()).toBe(true); + }); + + it.each([INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE])('Set up token reset for %s', (type) => { + createComponent({ props: { type } }); + + expect(findTokenResetDropdownItem().props('type')).toBe(type); + }); + }); + + it('Updates the token when it gets reset', async () => { + createComponent({}, mount); + + const newToken = 'mock1'; + + findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); + findToggleMaskButton().vm.$emit('click', { stopPropagation: jest.fn() }); + await nextTick(); + + expect(findTokenDropdownItem().text()).toMatchInterpolatedText( + `Registration token ${newToken}`, + ); + }); +}); diff --git a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js new file mode 100644 index 00000000000..0d002c272b4 --- /dev/null +++ b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -0,0 +1,194 @@ +import { GlDropdownItem, GlLoadingIcon, GlToast } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { nextTick } 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 RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; +import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; +import { captureException } from '~/runner/sentry_utils'; + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); +localVue.use(GlToast); + +const mockNewToken = 'NEW_TOKEN'; + +describe('RegistrationTokenResetDropdownItem', () => { + let wrapper; + let runnersRegistrationTokenResetMutationHandler; + let showToast; + + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const createComponent = ({ props, provide = {} } = {}) => { + wrapper = shallowMount(RegistrationTokenResetDropdownItem, { + localVue, + provide, + propsData: { + type: INSTANCE_TYPE, + ...props, + }, + apolloProvider: createMockApollo([ + [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], + ]), + }); + + showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; + }; + + beforeEach(() => { + runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({ + data: { + runnersRegistrationTokenReset: { + token: mockNewToken, + errors: [], + }, + }, + }); + + createComponent(); + + jest.spyOn(window, 'confirm'); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays reset button', () => { + expect(findDropdownItem().exists()).toBe(true); + }); + + describe('On click and confirmation', () => { + const mockGroupId = '11'; + const mockProjectId = '22'; + + describe.each` + type | provide | expectedInput + ${INSTANCE_TYPE} | ${{}} | ${{ type: INSTANCE_TYPE }} + ${GROUP_TYPE} | ${{ groupId: mockGroupId }} | ${{ type: GROUP_TYPE, id: `gid://gitlab/Group/${mockGroupId}` }} + ${PROJECT_TYPE} | ${{ projectId: mockProjectId }} | ${{ type: PROJECT_TYPE, id: `gid://gitlab/Project/${mockProjectId}` }} + `('Resets token of type $type', ({ type, provide, expectedInput }) => { + beforeEach(async () => { + createComponent({ + provide, + props: { type }, + }); + + window.confirm.mockReturnValueOnce(true); + + findDropdownItem().trigger('click'); + await waitForPromises(); + }); + + it('resets token', () => { + expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1); + expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({ + input: expectedInput, + }); + }); + + it('emits result', () => { + expect(wrapper.emitted('tokenReset')).toHaveLength(1); + expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]); + }); + + it('does not show a loading state', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('shows confirmation', () => { + expect(showToast).toHaveBeenLastCalledWith( + expect.stringContaining('registration token generated'), + ); + }); + }); + }); + + describe('On click without confirmation', () => { + beforeEach(async () => { + window.confirm.mockReturnValueOnce(false); + findDropdownItem().vm.$emit('click'); + await waitForPromises(); + }); + + it('does not reset token', () => { + expect(runnersRegistrationTokenResetMutationHandler).not.toHaveBeenCalled(); + }); + + it('does not emit any result', () => { + expect(wrapper.emitted('tokenReset')).toBeUndefined(); + }); + + it('does not show a loading state', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('does not shows confirmation', () => { + expect(showToast).not.toHaveBeenCalled(); + }); + }); + + describe('On error', () => { + it('On network error, error message is shown', async () => { + const mockErrorMsg = 'Token reset failed!'; + + runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + window.confirm.mockReturnValueOnce(true); + findDropdownItem().trigger('click'); + await waitForPromises(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: `Network error: ${mockErrorMsg}`, + }); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`Network error: ${mockErrorMsg}`), + component: 'RunnerRegistrationTokenReset', + }); + }); + + it('On validation error, error message is shown', async () => { + const mockErrorMsg = 'User not allowed!'; + const mockErrorMsg2 = 'Type is not valid!'; + + runnersRegistrationTokenResetMutationHandler.mockResolvedValue({ + data: { + runnersRegistrationTokenReset: { + token: null, + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + window.confirm.mockReturnValueOnce(true); + findDropdownItem().trigger('click'); + await waitForPromises(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: `${mockErrorMsg} ${mockErrorMsg2}`, + }); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerRegistrationTokenReset', + }); + }); + }); + + describe('Immediately after click', () => { + it('shows loading state', async () => { + window.confirm.mockReturnValue(true); + findDropdownItem().trigger('click'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js new file mode 100644 index 00000000000..f53ae165344 --- /dev/null +++ b/spec/frontend/runner/components/registration/registration_token_spec.js @@ -0,0 +1,109 @@ +import { nextTick } from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RegistrationToken from '~/runner/components/registration/registration_token.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; + +const mockToken = '01234567890'; +const mockMasked = '***********'; + +describe('RegistrationToken', () => { + let wrapper; + let stopPropagation; + let showToast; + + const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); + const findCopyButton = () => wrapper.findComponent(ModalCopyButton); + + const vueWithGlToast = () => { + const localVue = createLocalVue(); + localVue.use(GlToast); + return localVue; + }; + + const createComponent = ({ props = {}, withGlToast = true } = {}) => { + const localVue = withGlToast ? vueWithGlToast() : undefined; + + wrapper = extendedWrapper( + shallowMount(RegistrationToken, { + propsData: { + value: mockToken, + ...props, + }, + localVue, + }), + ); + + showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; + }; + + beforeEach(() => { + stopPropagation = jest.fn(); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays masked value by default', () => { + expect(wrapper.text()).toBe(mockMasked); + }); + + it('Displays button to reveal token', () => { + expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); + }); + + it('Can copy the original token value', () => { + expect(findCopyButton().props('text')).toBe(mockToken); + }); + + describe('When the reveal icon is clicked', () => { + beforeEach(() => { + findToggleMaskButton().vm.$emit('click', { stopPropagation }); + }); + + it('Click event is not propagated', async () => { + expect(stopPropagation).toHaveBeenCalledTimes(1); + }); + + it('Displays the actual value', () => { + expect(wrapper.text()).toBe(mockToken); + }); + + it('Can copy the original token value', () => { + expect(findCopyButton().props('text')).toBe(mockToken); + }); + + it('Displays button to mask token', () => { + expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to hide'); + }); + + it('When user clicks again, displays masked value', async () => { + findToggleMaskButton().vm.$emit('click', { stopPropagation }); + await nextTick(); + + expect(wrapper.text()).toBe(mockMasked); + expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); + }); + }); + + describe('When the copy to clipboard button is clicked', () => { + it('shows a copied message', () => { + findCopyButton().vm.$emit('success'); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Registration token copied!'); + }); + + it('does not fail when toast is not defined', () => { + createComponent({ withGlToast: false }); + findCopyButton().vm.$emit('success'); + + // This block also tests for unhandled errors + expect(showToast).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_contacted_state_badge_spec.js b/spec/frontend/runner/components/runner_contacted_state_badge_spec.js new file mode 100644 index 00000000000..57a27f39826 --- /dev/null +++ b/spec/frontend/runner/components/runner_contacted_state_badge_spec.js @@ -0,0 +1,86 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerContactedStateBadge from '~/runner/components/runner_contacted_state_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_NOT_CONNECTED } from '~/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ runner = {} } = {}) => { + wrapper = shallowMount(RunnerContactedStateBadge, { + propsData: { + runner: { + contactedAt: '2021-01-01T00:00:00Z', + status: STATUS_ONLINE, + ...runner, + }, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useFakeTimers('legacy'); + + wrapper.destroy(); + }); + + it('renders online state', () => { + jest.setSystemTime(new Date('2021-01-01T00:01:00Z')); + + createComponent(); + + expect(wrapper.text()).toBe('online'); + expect(findBadge().props('variant')).toBe('success'); + expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago'); + }); + + it('renders offline state', () => { + jest.setSystemTime(new Date('2021-01-02T00:00:00Z')); + + createComponent({ + runner: { + status: STATUS_OFFLINE, + }, + }); + + expect(wrapper.text()).toBe('offline'); + expect(findBadge().props('variant')).toBe('muted'); + expect(getTooltip().value).toBe( + 'No recent contact from this runner; last contact was 1 day ago', + ); + }); + + it('renders not connected state', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_NOT_CONNECTED, + }, + }); + + expect(wrapper.text()).toBe('not connected'); + expect(findBadge().props('variant')).toBe('muted'); + expect(getTooltip().value).toMatch('This runner has never connected'); + }); + + it('does not fail when data is missing', () => { + createComponent({ + runner: { + status: null, + }, + }); + + expect(wrapper.text()).toBe(''); + }); +}); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index 46948af1f28..9ea0955f2a1 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -5,13 +5,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; -import { typeTokenConfig } from '~/runner/components/search_tokens/type_token_config'; -import { - PARAM_KEY_STATUS, - PARAM_KEY_RUNNER_TYPE, - PARAM_KEY_TAG, - STATUS_ACTIVE, -} from '~/runner/constants'; +import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ACTIVE, INSTANCE_TYPE } from '~/runner/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -31,6 +25,11 @@ describe('RunnerList', () => { ]; const mockActiveRunnersCount = 2; + const expectToHaveLastEmittedInput = (value) => { + const inputs = wrapper.emitted('input'); + expect(inputs[inputs.length - 1][0]).toEqual(value); + }; + const createComponent = ({ props = {}, options = {} } = {}) => { wrapper = extendedWrapper( shallowMount(RunnerFilteredSearchBar, { @@ -38,6 +37,7 @@ describe('RunnerList', () => { namespace: 'runners', tokens: [], value: { + runnerType: null, filters: [], sort: mockDefaultSort, }, @@ -86,7 +86,7 @@ describe('RunnerList', () => { it('sets tokens to the filtered search', () => { createComponent({ props: { - tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig], + tokens: [statusTokenConfig, tagTokenConfig], }, }); @@ -96,11 +96,6 @@ describe('RunnerList', () => { token: BaseToken, options: expect.any(Array), }), - expect.objectContaining({ - type: PARAM_KEY_RUNNER_TYPE, - token: BaseToken, - options: expect.any(Array), - }), expect.objectContaining({ type: PARAM_KEY_TAG, token: TagToken, @@ -123,6 +118,7 @@ describe('RunnerList', () => { createComponent({ props: { value: { + runnerType: INSTANCE_TYPE, sort: mockOtherSort, filters: mockFilters, }, @@ -142,30 +138,40 @@ describe('RunnerList', () => { .text(), ).toEqual('Last contact'); }); + + it('when the user sets a filter, the "search" preserves the other filters', () => { + findGlFilteredSearch().vm.$emit('input', mockFilters); + findGlFilteredSearch().vm.$emit('submit'); + + expectToHaveLastEmittedInput({ + runnerType: INSTANCE_TYPE, + filters: mockFilters, + sort: mockOtherSort, + pagination: { page: 1 }, + }); + }); }); it('when the user sets a filter, the "search" is emitted with filters', () => { findGlFilteredSearch().vm.$emit('input', mockFilters); findGlFilteredSearch().vm.$emit('submit'); - expect(wrapper.emitted('input')[0]).toEqual([ - { - filters: mockFilters, - sort: mockDefaultSort, - pagination: { page: 1 }, - }, - ]); + expectToHaveLastEmittedInput({ + runnerType: null, + filters: mockFilters, + sort: mockDefaultSort, + pagination: { page: 1 }, + }); }); it('when the user sets a sorting method, the "search" is emitted with the sort', () => { findSortOptions().at(1).vm.$emit('click'); - expect(wrapper.emitted('input')[0]).toEqual([ - { - filters: [], - sort: mockOtherSort, - pagination: { page: 1 }, - }, - ]); + expectToHaveLastEmittedInput({ + runnerType: null, + filters: [], + sort: mockOtherSort, + pagination: { page: 1 }, + }); }); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index e24dffea1eb..986e55a2132 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -1,6 +1,5 @@ import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import { cloneDeep } from 'lodash'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerList from '~/runner/components/runner_list.vue'; @@ -43,12 +42,10 @@ describe('RunnerList', () => { const headerLabels = findHeaders().wrappers.map((w) => w.text()); expect(headerLabels).toEqual([ - 'Type/State', - 'Runner', + 'Status', + 'Runner ID', 'Version', 'IP Address', - 'Projects', - 'Jobs', 'Tags', 'Last contact', '', // actions has no label @@ -65,7 +62,7 @@ describe('RunnerList', () => { const { id, description, version, ipAddress, shortSha } = mockRunners[0]; // Badges - expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('specific paused'); + expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('not connected paused'); // Runner summary expect(findCell({ fieldKey: 'summary' }).text()).toContain( @@ -76,8 +73,6 @@ describe('RunnerList', () => { // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('1'); - expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0'); expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); @@ -88,54 +83,6 @@ describe('RunnerList', () => { expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); }); - describe('Table data formatting', () => { - let mockRunnersCopy; - - beforeEach(() => { - mockRunnersCopy = cloneDeep(mockRunners); - }); - - it('Formats null project counts', () => { - mockRunnersCopy[0].projectCount = null; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('n/a'); - }); - - it('Formats 0 project counts', () => { - mockRunnersCopy[0].projectCount = 0; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('0'); - }); - - it('Formats big project counts', () => { - mockRunnersCopy[0].projectCount = 1000; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('1,000'); - }); - - it('Formats job counts', () => { - mockRunnersCopy[0].jobCount = 1000; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000'); - }); - - it('Formats big job counts with a plus symbol', () => { - mockRunnersCopy[0].jobCount = 1001; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000+'); - }); - }); - it('Shows runner identifier', () => { const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js deleted file mode 100644 index effef0e7ebf..00000000000 --- a/spec/frontend/runner/components/runner_manual_setup_help_spec.js +++ /dev/null @@ -1,122 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'helpers/test_constants'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import MaskedValue from '~/runner/components/helpers/masked_value.vue'; -import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; -import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; - -const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; -const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/'; - -describe('RunnerManualSetupHelp', () => { - let wrapper; - let originalGon; - - const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions); - const findRunnerRegistrationTokenReset = () => - wrapper.findComponent(RunnerRegistrationTokenReset); - const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton); - const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title'); - const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url'); - const findRegistrationToken = () => wrapper.findByTestId('registration-token'); - const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link'); - - const createComponent = ({ props = {} } = {}) => { - wrapper = extendedWrapper( - shallowMount(RunnerManualSetupHelp, { - provide: { - runnerInstallHelpPage: mockRunnerInstallHelpPage, - }, - propsData: { - registrationToken: mockRegistrationToken, - type: INSTANCE_TYPE, - ...props, - }, - stubs: { - MaskedValue, - GlSprintf, - }, - }), - ); - }; - - beforeAll(() => { - originalGon = global.gon; - global.gon = { gitlab_url: TEST_HOST }; - }); - - afterAll(() => { - global.gon = originalGon; - }); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('Title contains the shared runner type', () => { - createComponent({ props: { type: INSTANCE_TYPE } }); - - expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually'); - }); - - it('Title contains the group runner type', () => { - createComponent({ props: { type: GROUP_TYPE } }); - - expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually'); - }); - - it('Title contains the specific runner type', () => { - createComponent({ props: { type: PROJECT_TYPE } }); - - expect(findRunnerHelpTitle().text()).toMatchInterpolatedText( - 'Set up a specific runner manually', - ); - }); - - it('Runner Install Page link', () => { - expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage); - }); - - it('Displays the coordinator URL token', () => { - expect(findCoordinatorUrl().text()).toBe(TEST_HOST); - expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST); - }); - - it('Displays the runner instructions', () => { - expect(findRunnerInstructions().exists()).toBe(true); - }); - - it('Displays the registration token', async () => { - findRegistrationToken().find('[data-testid="toggle-masked"]').vm.$emit('click'); - - await nextTick(); - - expect(findRegistrationToken().text()).toBe(mockRegistrationToken); - expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken); - }); - - it('Displays the runner registration token reset button', () => { - expect(findRunnerRegistrationTokenReset().exists()).toBe(true); - }); - - it('Replaces the runner reset button', async () => { - const mockNewRegistrationToken = 'NEW_MOCK_REGISTRATION_TOKEN'; - - findRegistrationToken().find('[data-testid="toggle-masked"]').vm.$emit('click'); - findRunnerRegistrationTokenReset().vm.$emit('tokenReset', mockNewRegistrationToken); - - await nextTick(); - - expect(findRegistrationToken().text()).toBe(mockNewRegistrationToken); - expect(findClipboardButtons().at(1).props('text')).toBe(mockNewRegistrationToken); - }); -}); diff --git a/spec/frontend/runner/components/runner_paused_badge_spec.js b/spec/frontend/runner/components/runner_paused_badge_spec.js new file mode 100644 index 00000000000..18cfcfae864 --- /dev/null +++ b/spec/frontend/runner/components/runner_paused_badge_spec.js @@ -0,0 +1,45 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerStatePausedBadge from '~/runner/components/runner_paused_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerStatePausedBadge, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders paused state', () => { + expect(wrapper.text()).toBe('paused'); + expect(findBadge().props('variant')).toBe('danger'); + }); + + it('renders tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); + + it('passes arbitrary attributes to the badge', () => { + createComponent({ props: { size: 'sm' } }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/runner/components/runner_registration_token_reset_spec.js b/spec/frontend/runner/components/runner_registration_token_reset_spec.js deleted file mode 100644 index 8b360b88417..00000000000 --- a/spec/frontend/runner/components/runner_registration_token_reset_spec.js +++ /dev/null @@ -1,189 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import createFlash, { FLASH_TYPES } from '~/flash'; -import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; -import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; -import { captureException } from '~/runner/sentry_utils'; - -jest.mock('~/flash'); -jest.mock('~/runner/sentry_utils'); - -const localVue = createLocalVue(); -localVue.use(VueApollo); - -const mockNewToken = 'NEW_TOKEN'; - -describe('RunnerRegistrationTokenReset', () => { - let wrapper; - let runnersRegistrationTokenResetMutationHandler; - - const findButton = () => wrapper.findComponent(GlButton); - - const createComponent = ({ props, provide = {} } = {}) => { - wrapper = shallowMount(RunnerRegistrationTokenReset, { - localVue, - provide, - propsData: { - type: INSTANCE_TYPE, - ...props, - }, - apolloProvider: createMockApollo([ - [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], - ]), - }); - }; - - beforeEach(() => { - runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({ - data: { - runnersRegistrationTokenReset: { - token: mockNewToken, - errors: [], - }, - }, - }); - - createComponent(); - - jest.spyOn(window, 'confirm'); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('Displays reset button', () => { - expect(findButton().exists()).toBe(true); - }); - - describe('On click and confirmation', () => { - const mockGroupId = '11'; - const mockProjectId = '22'; - - describe.each` - type | provide | expectedInput - ${INSTANCE_TYPE} | ${{}} | ${{ type: INSTANCE_TYPE }} - ${GROUP_TYPE} | ${{ groupId: mockGroupId }} | ${{ type: GROUP_TYPE, id: `gid://gitlab/Group/${mockGroupId}` }} - ${PROJECT_TYPE} | ${{ projectId: mockProjectId }} | ${{ type: PROJECT_TYPE, id: `gid://gitlab/Project/${mockProjectId}` }} - `('Resets token of type $type', ({ type, provide, expectedInput }) => { - beforeEach(async () => { - createComponent({ - provide, - props: { type }, - }); - - window.confirm.mockReturnValueOnce(true); - findButton().vm.$emit('click'); - await waitForPromises(); - }); - - it('resets token', () => { - expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1); - expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({ - input: expectedInput, - }); - }); - - it('emits result', () => { - expect(wrapper.emitted('tokenReset')).toHaveLength(1); - expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]); - }); - - it('does not show a loading state', () => { - expect(findButton().props('loading')).toBe(false); - }); - - it('shows confirmation', () => { - expect(createFlash).toHaveBeenLastCalledWith({ - message: expect.stringContaining('registration token generated'), - type: FLASH_TYPES.SUCCESS, - }); - }); - }); - }); - - describe('On click without confirmation', () => { - beforeEach(async () => { - window.confirm.mockReturnValueOnce(false); - findButton().vm.$emit('click'); - await waitForPromises(); - }); - - it('does not reset token', () => { - expect(runnersRegistrationTokenResetMutationHandler).not.toHaveBeenCalled(); - }); - - it('does not emit any result', () => { - expect(wrapper.emitted('tokenReset')).toBeUndefined(); - }); - - it('does not show a loading state', () => { - expect(findButton().props('loading')).toBe(false); - }); - - it('does not shows confirmation', () => { - expect(createFlash).not.toHaveBeenCalled(); - }); - }); - - describe('On error', () => { - it('On network error, error message is shown', async () => { - const mockErrorMsg = 'Token reset failed!'; - - runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - - window.confirm.mockReturnValueOnce(true); - findButton().vm.$emit('click'); - await waitForPromises(); - - expect(createFlash).toHaveBeenLastCalledWith({ - message: `Network error: ${mockErrorMsg}`, - }); - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`Network error: ${mockErrorMsg}`), - component: 'RunnerRegistrationTokenReset', - }); - }); - - it('On validation error, error message is shown', async () => { - const mockErrorMsg = 'User not allowed!'; - const mockErrorMsg2 = 'Type is not valid!'; - - runnersRegistrationTokenResetMutationHandler.mockResolvedValue({ - data: { - runnersRegistrationTokenReset: { - token: null, - errors: [mockErrorMsg, mockErrorMsg2], - }, - }, - }); - - window.confirm.mockReturnValueOnce(true); - findButton().vm.$emit('click'); - await waitForPromises(); - - expect(createFlash).toHaveBeenLastCalledWith({ - message: `${mockErrorMsg} ${mockErrorMsg2}`, - }); - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), - component: 'RunnerRegistrationTokenReset', - }); - }); - }); - - describe('Immediately after click', () => { - it('shows loading state', async () => { - window.confirm.mockReturnValue(true); - findButton().vm.$emit('click'); - await nextTick(); - - expect(findButton().props('loading')).toBe(true); - }); - }); -}); diff --git a/spec/frontend/runner/components/runner_state_locked_badge_spec.js b/spec/frontend/runner/components/runner_state_locked_badge_spec.js deleted file mode 100644 index e92b671f5a1..00000000000 --- a/spec/frontend/runner/components/runner_state_locked_badge_spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import RunnerStateLockedBadge from '~/runner/components/runner_state_locked_badge.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - -describe('RunnerTypeBadge', () => { - let wrapper; - - const findBadge = () => wrapper.findComponent(GlBadge); - const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(RunnerStateLockedBadge, { - propsData: { - ...props, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders locked state', () => { - expect(wrapper.text()).toBe('locked'); - expect(findBadge().props('variant')).toBe('warning'); - }); - - it('renders tooltip', () => { - expect(getTooltip().value).toBeDefined(); - }); - - it('passes arbitrary attributes to the badge', () => { - createComponent({ props: { size: 'sm' } }); - - expect(findBadge().props('size')).toBe('sm'); - }); -}); diff --git a/spec/frontend/runner/components/runner_state_paused_badge_spec.js b/spec/frontend/runner/components/runner_state_paused_badge_spec.js deleted file mode 100644 index 8df56d6e3f3..00000000000 --- a/spec/frontend/runner/components/runner_state_paused_badge_spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import RunnerStatePausedBadge from '~/runner/components/runner_state_paused_badge.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - -describe('RunnerTypeBadge', () => { - let wrapper; - - const findBadge = () => wrapper.findComponent(GlBadge); - const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(RunnerStatePausedBadge, { - propsData: { - ...props, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders paused state', () => { - expect(wrapper.text()).toBe('paused'); - expect(findBadge().props('variant')).toBe('danger'); - }); - - it('renders tooltip', () => { - expect(getTooltip().value).toBeDefined(); - }); - - it('passes arbitrary attributes to the badge', () => { - createComponent({ props: { size: 'sm' } }); - - expect(findBadge().props('size')).toBe('sm'); - }); -}); diff --git a/spec/frontend/runner/components/runner_tag_spec.js b/spec/frontend/runner/components/runner_tag_spec.js index dda318f8153..bd05d4b2cfe 100644 --- a/spec/frontend/runner/components/runner_tag_spec.js +++ b/spec/frontend/runner/components/runner_tag_spec.js @@ -1,18 +1,35 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import RunnerTag from '~/runner/components/runner_tag.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +const mockTag = 'tag1'; describe('RunnerTag', () => { let wrapper; const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltipValue = () => getBinding(findBadge().element, 'gl-tooltip').value; + + const setDimensions = ({ scrollWidth, offsetWidth }) => { + jest.spyOn(findBadge().element, 'scrollWidth', 'get').mockReturnValue(scrollWidth); + jest.spyOn(findBadge().element, 'offsetWidth', 'get').mockReturnValue(offsetWidth); + + // Mock trigger resize + getBinding(findBadge().element, 'gl-resize-observer').value(); + }; const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(RunnerTag, { propsData: { - tag: 'tag1', + tag: mockTag, ...props, }, + directives: { + GlTooltip: createMockDirective(), + GlResizeObserver: createMockDirective(), + }, }); }; @@ -25,21 +42,36 @@ describe('RunnerTag', () => { }); it('Displays tag text', () => { - expect(wrapper.text()).toBe('tag1'); + expect(wrapper.text()).toBe(mockTag); }); it('Displays tags with correct style', () => { expect(findBadge().props()).toMatchObject({ - size: 'md', - variant: 'info', + size: 'sm', + variant: 'neutral', }); }); - it('Displays tags with small size', () => { + it('Displays tags with md size', () => { createComponent({ - props: { size: 'sm' }, + props: { size: 'md' }, }); - expect(findBadge().props('size')).toBe('sm'); + expect(findBadge().props('size')).toBe('md'); }); + + it.each` + case | scrollWidth | offsetWidth | expectedTooltip + ${'overflowing'} | ${110} | ${100} | ${mockTag} + ${'not overflowing'} | ${90} | ${100} | ${''} + ${'almost overflowing'} | ${100} | ${100} | ${''} + `( + 'Sets "$expectedTooltip" as tooltip when $case', + async ({ scrollWidth, offsetWidth, expectedTooltip }) => { + setDimensions({ scrollWidth, offsetWidth }); + await nextTick(); + + expect(getTooltipValue()).toBe(expectedTooltip); + }, + ); }); diff --git a/spec/frontend/runner/components/runner_tags_spec.js b/spec/frontend/runner/components/runner_tags_spec.js index b6487ade0d6..da89a659432 100644 --- a/spec/frontend/runner/components/runner_tags_spec.js +++ b/spec/frontend/runner/components/runner_tags_spec.js @@ -33,16 +33,16 @@ describe('RunnerTags', () => { }); it('Displays tags with correct style', () => { - expect(findBadge().props('size')).toBe('md'); - expect(findBadge().props('variant')).toBe('info'); + expect(findBadge().props('size')).toBe('sm'); + expect(findBadge().props('variant')).toBe('neutral'); }); - it('Displays tags with small size', () => { + it('Displays tags with md size', () => { createComponent({ - props: { size: 'sm' }, + props: { size: 'md' }, }); - expect(findBadge().props('size')).toBe('sm'); + expect(findBadge().props('size')).toBe('md'); }); it('Is empty when there are no tags', () => { diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js index e54e499743b..4023c75c9a8 100644 --- a/spec/frontend/runner/components/runner_type_alert_spec.js +++ b/spec/frontend/runner/components/runner_type_alert_spec.js @@ -23,11 +23,11 @@ describe('RunnerTypeAlert', () => { }); describe.each` - type | exampleText | anchor | variant - ${INSTANCE_TYPE} | ${'This runner is available to all groups and projects'} | ${'#shared-runners'} | ${'success'} - ${GROUP_TYPE} | ${'This runner is available to all projects and subgroups in a group'} | ${'#group-runners'} | ${'success'} - ${PROJECT_TYPE} | ${'This runner is associated with one or more projects'} | ${'#specific-runners'} | ${'info'} - `('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => { + type | exampleText | anchor + ${INSTANCE_TYPE} | ${'This runner is available to all groups and projects'} | ${'#shared-runners'} + ${GROUP_TYPE} | ${'This runner is available to all projects and subgroups in a group'} | ${'#group-runners'} + ${PROJECT_TYPE} | ${'This runner is associated with one or more projects'} | ${'#specific-runners'} + `('When it is an $type level runner', ({ type, exampleText, anchor }) => { beforeEach(() => { createComponent({ props: { type } }); }); @@ -36,8 +36,8 @@ describe('RunnerTypeAlert', () => { expect(wrapper.text()).toMatch(exampleText); }); - it(`Shows a ${variant} variant`, () => { - expect(findAlert().props('variant')).toBe(variant); + it(`Shows an "info" variant`, () => { + expect(findAlert().props('variant')).toBe('info'); }); it(`Links to anchor "${anchor}"`, () => { diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js index fb344e65389..7bb0a2e6e2f 100644 --- a/spec/frontend/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/runner/components/runner_type_badge_spec.js @@ -26,18 +26,18 @@ describe('RunnerTypeBadge', () => { }); describe.each` - type | text | variant - ${INSTANCE_TYPE} | ${'shared'} | ${'success'} - ${GROUP_TYPE} | ${'group'} | ${'success'} - ${PROJECT_TYPE} | ${'specific'} | ${'info'} - `('displays $type runner', ({ type, text, variant }) => { + type | text + ${INSTANCE_TYPE} | ${'shared'} + ${GROUP_TYPE} | ${'group'} + ${PROJECT_TYPE} | ${'specific'} + `('displays $type runner', ({ type, text }) => { beforeEach(() => { createComponent({ props: { type } }); }); - it(`as "${text}" with a ${variant} variant`, () => { + it(`as "${text}" with an "info" variant`, () => { expect(findBadge().text()).toBe(text); - expect(findBadge().props('variant')).toBe(variant); + expect(findBadge().props('variant')).toBe('info'); }); it('with a tooltip', () => { diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js new file mode 100644 index 00000000000..4871d9c470a --- /dev/null +++ b/spec/frontend/runner/components/runner_type_tabs_spec.js @@ -0,0 +1,109 @@ +import { GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; +import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants'; + +const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }; + +describe('RunnerTypeTabs', () => { + let wrapper; + + const findTabs = () => wrapper.findAll(GlTab); + const findActiveTab = () => + findTabs() + .filter((tab) => tab.attributes('active') === 'true') + .at(0); + + const createComponent = ({ props, ...options } = {}) => { + wrapper = shallowMount(RunnerTypeTabs, { + propsData: { + value: mockSearch, + ...props, + }, + stubs: { + GlTab, + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Renders options to filter runners', () => { + expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([ + 'All', + 'Instance', + 'Group', + 'Project', + ]); + }); + + it('"All" is selected by default', () => { + expect(findActiveTab().text()).toBe('All'); + }); + + it('Another tab can be preselected by the user', () => { + createComponent({ + props: { + value: { + ...mockSearch, + runnerType: INSTANCE_TYPE, + }, + }, + }); + + expect(findActiveTab().text()).toBe('Instance'); + }); + + describe('When the user selects a tab', () => { + const emittedValue = () => wrapper.emitted('input')[0][0]; + + beforeEach(() => { + findTabs().at(2).vm.$emit('click'); + }); + + it(`Runner type is emitted`, () => { + expect(emittedValue()).toEqual({ + ...mockSearch, + runnerType: GROUP_TYPE, + }); + }); + + it('Runner type is selected', async () => { + const newValue = emittedValue(); + await wrapper.setProps({ value: newValue }); + + expect(findActiveTab().text()).toBe('Group'); + }); + }); + + describe('When using a custom slot', () => { + const mockContent = 'content'; + + beforeEach(() => { + createComponent({ + scopedSlots: { + title: ` + + {{props.tab.title}} ${mockContent} + `, + }, + }); + }); + + it('Renders tabs with additional information', () => { + expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([ + `All ${mockContent}`, + `Instance ${mockContent}`, + `Group ${mockContent}`, + `Project ${mockContent}`, + ]); + }); + }); +}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 5f3aabd4bc3..39bca743c80 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlLink } from '@gitlab/ui'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; @@ -11,7 +12,7 @@ import { updateHistory } from '~/lib/utils/url_utility'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; -import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; import { @@ -19,8 +20,8 @@ import { CREATED_DESC, DEFAULT_SORT, INSTANCE_TYPE, + GROUP_TYPE, PARAM_KEY_STATUS, - PARAM_KEY_RUNNER_TYPE, STATUS_ACTIVE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; @@ -48,7 +49,7 @@ describe('GroupRunnersApp', () => { let wrapper; let mockGroupRunnersQuery; - const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationPrev = () => @@ -82,13 +83,13 @@ describe('GroupRunnersApp', () => { }); it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); }); it('shows the runners list', () => { - expect(findRunnerList().props('runners')).toEqual( - groupRunnersData.data.group.runners.edges.map(({ node }) => node), - ); + const runners = findRunnerList().props('runners'); + expect(runners).toEqual(groupRunnersData.data.group.runners.edges.map(({ node }) => node)); }); it('runner item links to the runner group page', async () => { @@ -117,16 +118,15 @@ describe('GroupRunnersApp', () => { it('sets tokens in the filtered search', () => { createComponent({ mountFn: mount }); - expect(findFilteredSearch().props('tokens')).toEqual([ + const tokens = findFilteredSearch().props('tokens'); + + expect(tokens).toHaveLength(1); + expect(tokens[0]).toEqual( expect.objectContaining({ type: PARAM_KEY_STATUS, options: expect.any(Array), }), - expect.objectContaining({ - type: PARAM_KEY_RUNNER_TYPE, - options: expect.any(Array), - }), - ]); + ); }); describe('shows the active runner count', () => { @@ -161,10 +161,8 @@ describe('GroupRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ - filters: [ - { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, - { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, - ], + runnerType: INSTANCE_TYPE, + filters: [{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }], sort: 'CREATED_DESC', pagination: { page: 1 }, }); @@ -182,11 +180,14 @@ describe('GroupRunnersApp', () => { }); describe('when a filter is selected by the user', () => { - beforeEach(() => { + beforeEach(async () => { findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], sort: CREATED_ASC, }); + + await nextTick(); }); it('updates the browser url', () => { diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index 3a0c3abe7bd..0fc7917663e 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -1,5 +1,6 @@ import { RUNNER_PAGE_SIZE } from '~/runner/constants'; import { + searchValidator, fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, @@ -10,13 +11,14 @@ describe('search_params.js', () => { { name: 'a default query', urlQuery: '', - search: { filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, + search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, }, { name: 'a single status', urlQuery: '?status[]=ACTIVE', search: { + runnerType: null, filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], pagination: { page: 1 }, sort: 'CREATED_DESC', @@ -27,6 +29,7 @@ describe('search_params.js', () => { name: 'a single term text search', urlQuery: '?search=something', search: { + runnerType: null, filters: [ { type: 'filtered-search-term', @@ -42,6 +45,7 @@ describe('search_params.js', () => { name: 'a two terms text search', urlQuery: '?search=something+else', search: { + runnerType: null, filters: [ { type: 'filtered-search-term', @@ -61,7 +65,8 @@ describe('search_params.js', () => { name: 'single instance type', urlQuery: '?runner_type[]=INSTANCE_TYPE', search: { - filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }], + runnerType: 'INSTANCE_TYPE', + filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC', }, @@ -71,6 +76,7 @@ describe('search_params.js', () => { name: 'multiple runner status', urlQuery: '?status[]=ACTIVE&status[]=PAUSED', search: { + runnerType: null, filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'status', value: { data: 'PAUSED', operator: '=' } }, @@ -84,10 +90,8 @@ describe('search_params.js', () => { name: 'multiple status, a single instance type and a non default sort', urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', search: { - filters: [ - { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, - { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, - ], + runnerType: 'INSTANCE_TYPE', + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], pagination: { page: 1 }, sort: 'CREATED_ASC', }, @@ -102,6 +106,7 @@ describe('search_params.js', () => { name: 'a tag', urlQuery: '?tag[]=tag-1', search: { + runnerType: null, filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], pagination: { page: 1 }, sort: 'CREATED_DESC', @@ -116,6 +121,7 @@ describe('search_params.js', () => { name: 'two tags', urlQuery: '?tag[]=tag-1&tag[]=tag-2', search: { + runnerType: null, filters: [ { type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } }, @@ -132,13 +138,19 @@ describe('search_params.js', () => { { name: 'the next page', urlQuery: '?page=2&after=AFTER_CURSOR', - search: { filters: [], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC' }, + search: { + runnerType: null, + filters: [], + pagination: { page: 2, after: 'AFTER_CURSOR' }, + sort: 'CREATED_DESC', + }, graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, }, { name: 'the previous page', urlQuery: '?page=2&before=BEFORE_CURSOR', search: { + runnerType: null, filters: [], pagination: { page: 2, before: 'BEFORE_CURSOR' }, sort: 'CREATED_DESC', @@ -150,9 +162,9 @@ describe('search_params.js', () => { urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', search: { + runnerType: 'INSTANCE_TYPE', filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, - { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, { type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } }, ], @@ -170,6 +182,14 @@ describe('search_params.js', () => { }, ]; + describe('searchValidator', () => { + examples.forEach(({ name, search }) => { + it(`Validates ${name} as a search object`, () => { + expect(searchValidator(search)).toBe(true); + }); + }); + }); + describe('fromUrlQueryToSearch', () => { examples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a search object`, () => { diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js index b93527c1fe9..3bea0748c47 100644 --- a/spec/frontend/search/sidebar/components/app_spec.js +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -1,13 +1,13 @@ import { GlButton, GlLink } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_QUERY } from 'jest/search/mock_data'; import GlobalSearchSidebar from '~/search/sidebar/components/app.vue'; import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue'; import StatusFilter from '~/search/sidebar/components/status_filter.vue'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('GlobalSearchSidebar', () => { let wrapper; @@ -20,28 +20,26 @@ describe('GlobalSearchSidebar', () => { const createComponent = (initialState) => { const store = new Vuex.Store({ state: { - query: MOCK_QUERY, + urlQuery: MOCK_QUERY, ...initialState, }, actions: actionSpies, }); wrapper = shallowMount(GlobalSearchSidebar, { - localVue, store, }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); const findSidebarForm = () => wrapper.find('form'); - const findStatusFilter = () => wrapper.find(StatusFilter); - const findConfidentialityFilter = () => wrapper.find(ConfidentialityFilter); - const findApplyButton = () => wrapper.find(GlButton); - const findResetLinkButton = () => wrapper.find(GlLink); + const findStatusFilter = () => wrapper.findComponent(StatusFilter); + const findConfidentialityFilter = () => wrapper.findComponent(ConfidentialityFilter); + const findApplyButton = () => wrapper.findComponent(GlButton); + const findResetLinkButton = () => wrapper.findComponent(GlLink); describe('template', () => { beforeEach(() => { @@ -61,10 +59,32 @@ describe('GlobalSearchSidebar', () => { }); }); + describe('ApplyButton', () => { + describe('when sidebarDirty is false', () => { + beforeEach(() => { + createComponent({ sidebarDirty: false }); + }); + + it('disables the button', () => { + expect(findApplyButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('when sidebarDirty is true', () => { + beforeEach(() => { + createComponent({ sidebarDirty: true }); + }); + + it('enables the button', () => { + expect(findApplyButton().attributes('disabled')).toBe(undefined); + }); + }); + }); + describe('ResetLinkButton', () => { describe('with no filter selected', () => { beforeEach(() => { - createComponent({ query: {} }); + createComponent({ urlQuery: {} }); }); it('does not render', () => { @@ -74,10 +94,20 @@ describe('GlobalSearchSidebar', () => { describe('with filter selected', () => { beforeEach(() => { - createComponent(); + createComponent({ urlQuery: MOCK_QUERY }); + }); + + it('does render', () => { + expect(findResetLinkButton().exists()).toBe(true); + }); + }); + + describe('with filter selected and user updated query back to default', () => { + beforeEach(() => { + createComponent({ urlQuery: MOCK_QUERY, query: {} }); }); - it('does render when a filter selected', () => { + it('does render', () => { expect(findResetLinkButton().exists()).toBe(true); }); }); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index b50248bb295..5f8cee8160f 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -5,7 +5,11 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import * as actions from '~/search/store/actions'; -import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; +import { + GROUPS_LOCAL_STORAGE_KEY, + PROJECTS_LOCAL_STORAGE_KEY, + SIDEBAR_PARAMS, +} from '~/search/store/constants'; import * as types from '~/search/store/mutation_types'; import createState from '~/search/store/state'; import * as storeUtils from '~/search/store/utils'; @@ -153,15 +157,24 @@ describe('Global Search Store Actions', () => { }); }); - describe('setQuery', () => { - const payload = { key: 'key1', value: 'value1' }; + describe.each` + payload | isDirty | isDirtyMutation + ${{ key: SIDEBAR_PARAMS[0], value: 'test' }} | ${false} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: false }]} + ${{ key: SIDEBAR_PARAMS[0], value: 'test' }} | ${true} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: true }]} + ${{ key: SIDEBAR_PARAMS[1], value: 'test' }} | ${false} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: false }]} + ${{ key: SIDEBAR_PARAMS[1], value: 'test' }} | ${true} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: true }]} + ${{ key: 'non-sidebar', value: 'test' }} | ${false} | ${[]} + ${{ key: 'non-sidebar', value: 'test' }} | ${true} | ${[]} + `('setQuery', ({ payload, isDirty, isDirtyMutation }) => { + describe(`when filter param is ${payload.key} and utils.isSidebarDirty returns ${isDirty}`, () => { + const expectedMutations = [{ type: types.SET_QUERY, payload }].concat(isDirtyMutation); - it('calls the SET_QUERY mutation', () => { - return testAction({ - action: actions.setQuery, - payload, - state, - expectedMutations: [{ type: types.SET_QUERY, payload }], + beforeEach(() => { + storeUtils.isSidebarDirty = jest.fn().mockReturnValue(isDirty); + }); + + it(`should dispatch the correct mutations`, () => { + return testAction({ action: actions.setQuery, payload, state, expectedMutations }); }); }); }); diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js index a60718a972d..25f9b692955 100644 --- a/spec/frontend/search/store/mutations_spec.js +++ b/spec/frontend/search/store/mutations_spec.js @@ -72,6 +72,16 @@ describe('Global Search Store Mutations', () => { }); }); + describe('SET_SIDEBAR_DIRTY', () => { + const value = true; + + it('sets sidebarDirty to the value', () => { + mutations[types.SET_SIDEBAR_DIRTY](state, value); + + expect(state.sidebarDirty).toBe(value); + }); + }); + describe('LOAD_FREQUENT_ITEMS', () => { it('sets frequentItems[key] to data', () => { const payload = { key: 'test-key', data: [1, 2, 3] }; diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js index bcdad9f89dd..20d764190b1 100644 --- a/spec/frontend/search/store/utils_spec.js +++ b/spec/frontend/search/store/utils_spec.js @@ -1,6 +1,11 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { MAX_FREQUENCY } from '~/search/store/constants'; -import { loadDataFromLS, setFrequentItemToLS, mergeById } from '~/search/store/utils'; +import { MAX_FREQUENCY, SIDEBAR_PARAMS } from '~/search/store/constants'; +import { + loadDataFromLS, + setFrequentItemToLS, + mergeById, + isSidebarDirty, +} from '~/search/store/utils'; import { MOCK_LS_KEY, MOCK_GROUPS, @@ -216,4 +221,24 @@ describe('Global Search Store Utils', () => { }); }); }); + + describe.each` + description | currentQuery | urlQuery | isDirty + ${'identical'} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default' }} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default' }} | ${false} + ${'different'} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'new' }} | ${{ [SIDEBAR_PARAMS[0]]: 'default', [SIDEBAR_PARAMS[1]]: 'default' }} | ${true} + ${'null/undefined'} | ${{ [SIDEBAR_PARAMS[0]]: null, [SIDEBAR_PARAMS[1]]: null }} | ${{ [SIDEBAR_PARAMS[0]]: undefined, [SIDEBAR_PARAMS[1]]: undefined }} | ${false} + ${'updated/undefined'} | ${{ [SIDEBAR_PARAMS[0]]: 'new', [SIDEBAR_PARAMS[1]]: 'new' }} | ${{ [SIDEBAR_PARAMS[0]]: undefined, [SIDEBAR_PARAMS[1]]: undefined }} | ${true} + `('isSidebarDirty', ({ description, currentQuery, urlQuery, isDirty }) => { + describe(`with ${description} sidebar query data`, () => { + let res; + + beforeEach(() => { + res = isSidebarDirty(currentQuery, urlQuery); + }); + + it(`returns ${isDirty}`, () => { + expect(res).toStrictEqual(isDirty); + }); + }); + }); }); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index f27f45f2b26..d4ee9e6e43d 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,5 +1,6 @@ import { GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import stubChildren from 'helpers/stub_children'; @@ -70,6 +71,7 @@ describe('App component', () => { const findTabs = () => wrapper.findAllComponents(GlTab); const findByTestId = (id) => wrapper.findByTestId(id); const findFeatureCards = () => wrapper.findAllComponents(FeatureCard); + const findManageViaMRErrorAlert = () => wrapper.findByTestId('manage-via-mr-error-alert'); const findLink = ({ href, text, container = wrapper }) => { const selector = `a[href="${href}"]`; const link = container.find(selector); @@ -132,12 +134,12 @@ describe('App component', () => { it('renders main-heading with correct text', () => { const mainHeading = findMainHeading(); - expect(mainHeading).toExist(); + expect(mainHeading.exists()).toBe(true); expect(mainHeading.text()).toContain('Security Configuration'); }); it('renders GlTab Component ', () => { - expect(findTab()).toExist(); + expect(findTab().exists()).toBe(true); }); it('renders right amount of tabs with correct title ', () => { @@ -173,6 +175,43 @@ describe('App component', () => { }); }); + describe('Manage via MR Error Alert', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + }); + }); + + describe('on initial load', () => { + it('should not show Manage via MR Error Alert', () => { + expect(findManageViaMRErrorAlert().exists()).toBe(false); + }); + }); + + describe('when error occurs', () => { + it('should show Alert with error Message', async () => { + expect(findManageViaMRErrorAlert().exists()).toBe(false); + findFeatureCards().at(1).vm.$emit('error', 'There was a manage via MR error'); + + await nextTick(); + expect(findManageViaMRErrorAlert().exists()).toBe(true); + expect(findManageViaMRErrorAlert().text()).toEqual('There was a manage via MR error'); + }); + + it('should hide Alert when it is dismissed', async () => { + findFeatureCards().at(1).vm.$emit('error', 'There was a manage via MR error'); + + await nextTick(); + expect(findManageViaMRErrorAlert().exists()).toBe(true); + + findManageViaMRErrorAlert().vm.$emit('dismiss'); + await nextTick(); + expect(findManageViaMRErrorAlert().exists()).toBe(false); + }); + }); + }); + describe('Auto DevOps hint alert', () => { describe('given the right props', () => { beforeEach(() => { diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index fdb1d2f86e3..0eca2c27075 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -80,7 +80,11 @@ describe('FeatureCard component', () => { describe('basic structure', () => { beforeEach(() => { - feature = makeFeature(); + feature = makeFeature({ + type: 'sast', + available: true, + canEnableByMergeRequest: true, + }); createComponent({ feature }); }); @@ -97,6 +101,11 @@ describe('FeatureCard component', () => { expect(links.exists()).toBe(true); expect(links).toHaveLength(1); }); + + it('should catch and emit manage-via-mr-error', () => { + findManageViaMr().vm.$emit('error', 'There was a manage via MR error'); + expect(wrapper.emitted('error')).toEqual([['There was a manage via MR error']]); + }); }); describe('status', () => { diff --git a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js index 7e81df1d7d2..c72c23a3a60 100644 --- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js @@ -10,7 +10,7 @@ const DEFAULT_RENDER_COUNT = 5; describe('UncollapsedAssigneeList component', () => { let wrapper; - function createComponent(props = {}) { + function createComponent(props = {}, glFeatures = {}) { const propsData = { users: [], rootPath: TEST_HOST, @@ -19,6 +19,7 @@ describe('UncollapsedAssigneeList component', () => { wrapper = mount(UncollapsedAssigneeList, { propsData, + provide: { glFeatures }, }); } @@ -99,4 +100,22 @@ describe('UncollapsedAssigneeList component', () => { }); }); }); + + describe('merge requests', () => { + it.each` + numberOfUsers + ${1} + ${5} + `('displays as a vertical list for $numberOfUsers of users', ({ numberOfUsers }) => { + createComponent( + { + users: UsersMockHelper.createNumberRandomUsers(numberOfUsers), + issuableType: 'merge_request', + }, + { mrAttentionRequests: true }, + ); + + expect(wrapper.findAll('[data-testid="username"]').length).toBe(numberOfUsers); + }); + }); }); diff --git a/spec/frontend/sidebar/components/attention_required_toggle_spec.js b/spec/frontend/sidebar/components/attention_required_toggle_spec.js new file mode 100644 index 00000000000..8555068cdd8 --- /dev/null +++ b/spec/frontend/sidebar/components/attention_required_toggle_spec.js @@ -0,0 +1,84 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import AttentionRequestedToggle from '~/sidebar/components/attention_requested_toggle.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = mount(AttentionRequestedToggle, { propsData }); +} + +const findToggle = () => wrapper.findComponent(GlButton); + +describe('Attention require toggle', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('renders button', () => { + factory({ type: 'reviewer', user: { attention_requested: false } }); + + expect(findToggle().exists()).toBe(true); + }); + + it.each` + attentionRequested | icon + ${true} | ${'star'} + ${false} | ${'star-o'} + `( + 'renders $icon icon when attention_requested is $attentionRequested', + ({ attentionRequested, icon }) => { + factory({ type: 'reviewer', user: { attention_requested: attentionRequested } }); + + expect(findToggle().props('icon')).toBe(icon); + }, + ); + + it.each` + attentionRequested | variant + ${true} | ${'warning'} + ${false} | ${'default'} + `( + 'renders button with variant $variant when attention_requested is $attentionRequested', + ({ attentionRequested, variant }) => { + factory({ type: 'reviewer', user: { attention_requested: attentionRequested } }); + + expect(findToggle().props('variant')).toBe(variant); + }, + ); + + it('emits toggle-attention-requested on click', async () => { + factory({ type: 'reviewer', user: { attention_requested: true } }); + + await findToggle().trigger('click'); + + expect(wrapper.emitted('toggle-attention-requested')[0]).toEqual([ + { + user: { attention_requested: true }, + callback: expect.anything(), + }, + ]); + }); + + it('sets loading on click', async () => { + factory({ type: 'reviewer', user: { attention_requested: true } }); + + await findToggle().trigger('click'); + + expect(findToggle().props('loading')).toBe(true); + }); + + it.each` + type | attentionRequested | tooltip + ${'reviewer'} | ${true} | ${AttentionRequestedToggle.i18n.removeAttentionRequested} + ${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.attentionRequestedReviewer} + ${'assignee'} | ${false} | ${AttentionRequestedToggle.i18n.attentionRequestedAssignee} + `( + 'sets tooltip as $tooltip when attention_requested is $attentionRequested and type is $type', + ({ type, attentionRequested, tooltip }) => { + factory({ type, user: { attention_requested: attentionRequested } }); + + expect(findToggle().attributes('aria-label')).toBe(tooltip); + }, + ); +}); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index 6b80224083a..13887f28d22 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; +import AttentionRequestedToggle from '~/sidebar/components/attention_requested_toggle.vue'; import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue'; import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue'; import userDataMock from '../../user_data_mock'; @@ -9,7 +10,7 @@ describe('UncollapsedReviewerList component', () => { const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]'); - function createComponent(props = {}) { + function createComponent(props = {}, glFeatures = {}) { const propsData = { users: [], rootPath: TEST_HOST, @@ -18,6 +19,9 @@ describe('UncollapsedReviewerList component', () => { wrapper = shallowMount(UncollapsedReviewerList, { propsData, + provide: { + glFeatures, + }, }); } @@ -110,4 +114,18 @@ describe('UncollapsedReviewerList component', () => { expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true); }); }); + + it('hides re-request review button when attentionRequired feature flag is enabled', () => { + createComponent({ users: [userDataMock()] }, { mrAttentionRequests: true }); + + expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(0); + }); + + it('emits toggle-attention-requested', () => { + createComponent({ users: [userDataMock()] }, { mrAttentionRequests: true }); + + wrapper.find(AttentionRequestedToggle).vm.$emit('toggle-attention-requested', 'data'); + + expect(wrapper.emitted('toggle-attention-requested')[0]).toEqual(['data']); + }); }); diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index 66218626e6b..64d143615a0 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -50,7 +50,7 @@ describe('Issuable Time Tracking Report', () => { it('should render loading spinner', () => { mountComponent(); - expect(findLoadingIcon()).toExist(); + expect(findLoadingIcon().exists()).toBe(true); }); it('should render error message on reject', async () => { diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index cb84c142d55..3d7baaff10a 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -4,8 +4,11 @@ import * as urlUtility from '~/lib/utils/url_utility'; import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; +import toast from '~/vue_shared/plugins/global_toast'; import Mock from './mock_data'; +jest.mock('~/vue_shared/plugins/global_toast'); + describe('Sidebar mediator', () => { const { mediator: mediatorMockData } = Mock; let mock; @@ -115,4 +118,56 @@ describe('Sidebar mediator', () => { urlSpy.mockRestore(); }); }); + + describe('toggleAttentionRequested', () => { + let attentionRequiredService; + + beforeEach(() => { + attentionRequiredService = jest + .spyOn(mediator.service, 'toggleAttentionRequested') + .mockResolvedValue(); + }); + + it('calls attentionRequired service method', async () => { + mediator.store.reviewers = [{ id: 1, attention_requested: false, username: 'root' }]; + + await mediator.toggleAttentionRequested('reviewer', { + user: { id: 1, username: 'root' }, + callback: jest.fn(), + }); + + expect(attentionRequiredService).toHaveBeenCalledWith(1); + }); + + it.each` + type | method + ${'reviewer'} | ${'findReviewer'} + `('finds $type', ({ type, method }) => { + const methodSpy = jest.spyOn(mediator.store, method); + + mediator.toggleAttentionRequested(type, { user: { id: 1 }, callback: jest.fn() }); + + expect(methodSpy).toHaveBeenCalledWith({ id: 1 }); + }); + + it.each` + attentionRequested | toastMessage + ${true} | ${'Removed attention request from @root'} + ${false} | ${'Requested attention from @root'} + `( + 'it creates toast $toastMessage when attention_requested is $attentionRequested', + async ({ attentionRequested, toastMessage }) => { + mediator.store.reviewers = [ + { id: 1, attention_requested: attentionRequested, username: 'root' }, + ]; + + await mediator.toggleAttentionRequested('reviewer', { + user: { id: 1, username: 'root' }, + callback: jest.fn(), + }); + + expect(toast).toHaveBeenCalledWith(toastMessage); + }, + ); + }); }); diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js index 2d7a735bd11..bf470e7e126 100644 --- a/spec/frontend/task_list_spec.js +++ b/spec/frontend/task_list_spec.js @@ -125,6 +125,7 @@ describe('TaskList', () => { const response = { data: { lock_version: 3 } }; jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); + jest.spyOn(taskList, 'onUpdate').mockImplementation(() => {}); jest.spyOn(taskList, 'onSuccess').mockImplementation(() => {}); jest.spyOn(axios, 'patch').mockReturnValue(Promise.resolve(response)); @@ -151,8 +152,11 @@ describe('TaskList', () => { }, }; - taskList - .update(event) + const update = taskList.update(event); + + expect(taskList.onUpdate).toHaveBeenCalled(); + + update .then(() => { expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); @@ -168,12 +172,17 @@ describe('TaskList', () => { it('should handle request error and enable task list items', (done) => { const response = { data: { error: 1 } }; jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); + jest.spyOn(taskList, 'onUpdate').mockImplementation(() => {}); jest.spyOn(taskList, 'onError').mockImplementation(() => {}); jest.spyOn(axios, 'patch').mockReturnValue(Promise.reject({ response })); // eslint-disable-line prefer-promise-reject-errors const event = { detail: {} }; - taskList - .update(event) + + const update = taskList.update(event); + + expect(taskList.onUpdate).toHaveBeenCalled(); + + update .then(() => { expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); expect(taskList.onError).toHaveBeenCalledWith(response.data); diff --git a/spec/frontend/terms/components/app_spec.js b/spec/frontend/terms/components/app_spec.js new file mode 100644 index 00000000000..ee78b35843a --- /dev/null +++ b/spec/frontend/terms/components/app_spec.js @@ -0,0 +1,171 @@ +import $ from 'jquery'; +import { merge } from 'lodash'; +import { GlIntersectionObserver } from '@gitlab/ui'; +import { nextTick } from 'vue'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import TermsApp from '~/terms/components/app.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); +jest.mock('~/lib/utils/common_utils'); + +describe('TermsApp', () => { + let wrapper; + let renderGFMSpy; + + const defaultProvide = { + terms: 'foo bar', + paths: { + accept: '/-/users/terms/1/accept', + decline: '/-/users/terms/1/decline', + root: '/', + }, + permissions: { + canAccept: true, + canDecline: true, + }, + }; + + const createComponent = (provide = {}) => { + wrapper = mountExtended(TermsApp, { + provide: merge({}, defaultProvide, provide), + }); + }; + + beforeEach(() => { + renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); + isLoggedIn.mockReturnValue(true); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findFormWithAction = (path) => wrapper.find(`form[action="${path}"]`); + const findButton = (path) => findFormWithAction(path).find('button[type="submit"]'); + const findScrollableViewport = () => wrapper.findByTestId('scrollable-viewport'); + + const expectFormWithSubmitButton = (buttonText, path) => { + const form = findFormWithAction(path); + const submitButton = findButton(path); + + expect(form.exists()).toBe(true); + expect(submitButton.exists()).toBe(true); + expect(submitButton.text()).toBe(buttonText); + expect( + form + .find('input[type="hidden"][name="authenticity_token"][value="mock-csrf-token"]') + .exists(), + ).toBe(true); + }; + + it('renders terms of service as markdown', () => { + createComponent(); + + expect(wrapper.findByText(defaultProvide.terms).exists()).toBe(true); + expect(renderGFMSpy).toHaveBeenCalled(); + }); + + describe('accept button', () => { + it('is disabled until user scrolls to the bottom of the terms', async () => { + createComponent(); + + expect(findButton(defaultProvide.paths.accept).attributes('disabled')).toBe('disabled'); + + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + + await nextTick(); + + expect(findButton(defaultProvide.paths.accept).attributes('disabled')).toBeUndefined(); + }); + + describe('when user has permissions to accept', () => { + it('renders form and button to accept terms', () => { + createComponent(); + + expectFormWithSubmitButton(TermsApp.i18n.accept, defaultProvide.paths.accept); + }); + }); + + describe('when user does not have permissions to accept', () => { + it('renders continue button', () => { + createComponent({ permissions: { canAccept: false } }); + + expect(wrapper.findByText(TermsApp.i18n.continue).exists()).toBe(true); + }); + }); + }); + + describe('decline button', () => { + describe('when user has permissions to decline', () => { + it('renders form and button to decline terms', () => { + createComponent(); + + expectFormWithSubmitButton(TermsApp.i18n.decline, defaultProvide.paths.decline); + }); + }); + + describe('when user does not have permissions to decline', () => { + it('does not render decline button', () => { + createComponent({ permissions: { canDecline: false } }); + + expect(wrapper.findByText(TermsApp.i18n.decline).exists()).toBe(false); + }); + }); + }); + + it('sets height of scrollable viewport', () => { + jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 800); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600); + + createComponent(); + + expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 200px);'); + }); + + describe('when flash is closed', () => { + let flashEl; + + beforeEach(() => { + flashEl = document.createElement('div'); + flashEl.classList.add(`flash-${FLASH_TYPES.ALERT}`); + document.body.appendChild(flashEl); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('recalculates height of scrollable viewport', () => { + jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 800); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600); + + createComponent(); + + expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 200px);'); + + jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 700); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600); + + flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT)); + + expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 100px);'); + }); + }); + + describe('when user is signed out', () => { + beforeEach(() => { + isLoggedIn.mockReturnValue(false); + }); + + it('does not show any buttons', () => { + createComponent(); + + expect(wrapper.findByText(TermsApp.i18n.accept).exists()).toBe(false); + expect(wrapper.findByText(TermsApp.i18n.decline).exists()).toBe(false); + expect(wrapper.findByText(TermsApp.i18n.continue).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 2c8e0fff848..40f68c6385f 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -47,10 +47,12 @@ Object.assign(global, { setFixtures: setHTMLFixture, }); +const JQUERY_MATCHERS_TO_EXCLUDE = ['toHaveLength', 'toExist']; + // custom-jquery-matchers was written for an old Jest version, we need to make it compatible Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => { - // Don't override existing Jest matcher - if (matcherName === 'toHaveLength') { + // Exclude these jQuery matchers + if (JQUERY_MATCHERS_TO_EXCLUDE.includes(matcherName)) { return; } diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js index c9dea4394f9..c2606346292 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js @@ -1,14 +1,20 @@ import { shallowMount } from '@vue/test-utils'; import { toNounSeriesText } from '~/lib/utils/grammar'; import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; -import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; +import { + APPROVED_BY_OTHERS, + APPROVED_BY_YOU, + APPROVED_BY_YOU_AND_OTHERS, +} from '~/vue_merge_request_widget/components/approvals/messages'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +const exampleUserId = 1; const testApprovers = () => Array.from({ length: 5 }, (_, i) => i).map((id) => ({ id })); const testRulesLeft = () => ['Lorem', 'Ipsum', 'dolar & sit']; const TEST_APPROVALS_LEFT = 3; describe('MRWidget approvals summary', () => { + const originalUserId = gon.current_user_id; let wrapper; const createComponent = (props = {}) => { @@ -28,6 +34,7 @@ describe('MRWidget approvals summary', () => { afterEach(() => { wrapper.destroy(); wrapper = null; + gon.current_user_id = originalUserId; }); describe('when approved', () => { @@ -38,7 +45,7 @@ describe('MRWidget approvals summary', () => { }); it('shows approved message', () => { - expect(wrapper.text()).toContain(APPROVED_MESSAGE); + expect(wrapper.text()).toContain(APPROVED_BY_OTHERS); }); it('renders avatar list for approvers', () => { @@ -51,6 +58,48 @@ describe('MRWidget approvals summary', () => { }), ); }); + + describe('by the current user', () => { + beforeEach(() => { + gon.current_user_id = exampleUserId; + createComponent({ + approvers: [{ id: exampleUserId }], + approved: true, + }); + }); + + it('shows "Approved by you" message', () => { + expect(wrapper.text()).toContain(APPROVED_BY_YOU); + }); + }); + + describe('by the current user and others', () => { + beforeEach(() => { + gon.current_user_id = exampleUserId; + createComponent({ + approvers: [{ id: exampleUserId }, { id: exampleUserId + 1 }], + approved: true, + }); + }); + + it('shows "Approved by you and others" message', () => { + expect(wrapper.text()).toContain(APPROVED_BY_YOU_AND_OTHERS); + }); + }); + + describe('by other users than the current user', () => { + beforeEach(() => { + gon.current_user_id = exampleUserId; + createComponent({ + approvers: [{ id: exampleUserId + 1 }], + approved: true, + }); + }); + + it('shows "Approved by others" message', () => { + expect(wrapper.text()).toContain(APPROVED_BY_OTHERS); + }); + }); }); describe('when not approved', () => { diff --git a/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js b/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js index d5d779d7a34..a13db2f4d72 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js @@ -24,6 +24,18 @@ describe('MR widget extension actions', () => { expect(wrapper.findAllComponents(GlButton)).toHaveLength(1); }); + it('calls action click handler', async () => { + const onClick = jest.fn(); + + factory({ + tertiaryButtons: [{ text: 'hello world', onClick }], + }); + + await wrapper.findComponent(GlButton).vm.$emit('click'); + + expect(onClick).toHaveBeenCalled(); + }); + it('renders tertiary actions in dropdown', () => { factory({ tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index ecaca16a2cd..6347e3c3be3 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -1,5 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import { trimText } from 'helpers/text_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; @@ -39,6 +41,8 @@ describe('MRWidgetPipeline', () => { const findMonitoringPipelineMessage = () => wrapper.findByTestId('monitoring-pipeline-message'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const mockArtifactsRequest = () => new MockAdapter(axios).onGet().reply(200, []); + const createWrapper = (props = {}, mountFn = shallowMount) => { wrapper = extendedWrapper( mountFn(PipelineComponent, { @@ -71,6 +75,8 @@ describe('MRWidgetPipeline', () => { describe('with a pipeline', () => { beforeEach(() => { + mockArtifactsRequest(); + createWrapper( { pipelineCoverageDelta: mockData.pipelineCoverageDelta, diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js index b5afc1ab21a..8e710b6d65f 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; +import { GlSprintf } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; @@ -7,9 +7,7 @@ import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.v import suggestPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue'; import { SP_TRACK_LABEL, - SP_LINK_TRACK_EVENT, SP_SHOW_TRACK_EVENT, - SP_LINK_TRACK_VALUE, SP_SHOW_TRACK_VALUE, SP_HELP_URL, } from '~/vue_merge_request_widget/constants'; @@ -52,15 +50,8 @@ describe('MRWidgetSuggestPipeline', () => { mockAxios.restore(); }); - it('renders add pipeline file link', () => { - const link = wrapper.find(GlLink); - - expect(link.exists()).toBe(true); - expect(link.attributes().href).toBe(suggestProps.pipelinePath); - }); - it('renders the expected text', () => { - const messageText = /\s*No pipeline\s*Add the .gitlab-ci.yml file\s*to create one./; + const messageText = /Looks like there's no pipeline here./; expect(wrapper.text()).toMatch(messageText); }); @@ -109,18 +100,6 @@ describe('MRWidgetSuggestPipeline', () => { }); }); - it('send an event when add pipeline link is clicked', () => { - mockTrackingOnWrapper(); - const link = wrapper.find('[data-testid="add-pipeline-link"]'); - triggerEvent(link.element); - - expect(trackingSpy).toHaveBeenCalledWith('_category_', SP_LINK_TRACK_EVENT, { - label: SP_TRACK_LABEL, - property: suggestProps.humanAccess, - value: SP_LINK_TRACK_VALUE.toString(), - }); - }); - it('send an event when ok button is clicked', () => { mockTrackingOnWrapper(); const okBtn = findOkBtn(); diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap index 5981d2d7849..56a0218b374 100644 --- a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap +++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap @@ -50,7 +50,7 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have - The source branch will not be deleted + Does not delete the source branch - The source branch will not be deleted + Does not delete the source branch

    Ready to merge by members who can write to the target branch. @@ -27,7 +27,7 @@ exports[`New ready to merge state component renders permission text if canMerge />

    Ready to merge! diff --git a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js index 8214cedc4a1..f965fc32dc1 100644 --- a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js @@ -3,6 +3,7 @@ import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit const testCommitMessage = 'Test commit message'; const testLabel = 'Test label'; +const testTextMuted = 'Test text muted'; const testInputId = 'test-input-id'; describe('Commits edit component', () => { @@ -63,7 +64,7 @@ describe('Commits edit component', () => { beforeEach(() => { createComponent({ header: `

    ${testCommitMessage}
    `, - checkbox: ``, + 'text-muted': `

    ${testTextMuted}

    `, }); }); @@ -74,11 +75,11 @@ describe('Commits edit component', () => { expect(headerSlotElement.text()).toBe(testCommitMessage); }); - it('renders checkbox slot correctly', () => { - const checkboxSlotElement = wrapper.find('.test-checkbox'); + it('renders text-muted slot correctly', () => { + const textMutedElement = wrapper.find('.test-text-muted'); - expect(checkboxSlotElement.exists()).toBe(true); - expect(checkboxSlotElement.text()).toBe(testLabel); + expect(textMutedElement.exists()).toBe(true); + expect(textMutedElement.text()).toBe(testTextMuted); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index 4c1534574f5..d0a6af9970e 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -270,8 +270,8 @@ describe('MRWidgetAutoMergeEnabled', () => { const normalizedText = wrapper.text().replace(/\s+/g, ' '); - expect(normalizedText).toContain('The source branch will be deleted'); - expect(normalizedText).not.toContain('The source branch will not be deleted'); + expect(normalizedText).toContain('Deletes the source branch'); + expect(normalizedText).not.toContain('Does not delete the source branch'); }); it('should not show delete source branch button when user not able to delete source branch', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js index 2ff94a547f4..5858654e518 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { GlSprintf } from '@gitlab/ui'; import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; @@ -6,7 +6,7 @@ describe('Commits header component', () => { let wrapper; const createComponent = (props) => { - wrapper = shallowMount(CommitsHeader, { + wrapper = mount(CommitsHeader, { stubs: { GlSprintf, }, 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 9c3a6d581e8..e0f1f091129 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 @@ -191,7 +191,7 @@ describe('MRWidgetMerged', () => { }); it('shows button to copy commit SHA to clipboard', () => { - expect(selectors.copyMergeShaButton).toExist(); + expect(selectors.copyMergeShaButton).not.toBe(null); expect(selectors.copyMergeShaButton.getAttribute('data-clipboard-text')).toBe( vm.mr.mergeCommitSha, ); @@ -201,14 +201,14 @@ describe('MRWidgetMerged', () => { vm.mr.mergeCommitSha = null; Vue.nextTick(() => { - expect(selectors.copyMergeShaButton).not.toExist(); + expect(selectors.copyMergeShaButton).toBe(null); expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with'); done(); }); }); it('shows merge commit SHA link', () => { - expect(selectors.mergeCommitShaLink).toExist(); + expect(selectors.mergeCommitShaLink).not.toBe(null); expect(selectors.mergeCommitShaLink.text).toContain(vm.mr.shortMergeCommitSha); expect(selectors.mergeCommitShaLink.href).toBe(vm.mr.mergeCommitPath); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js index b6c16958993..e6b2e9fa176 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js @@ -42,7 +42,7 @@ describe('MRWidgetMerging', () => { .trim() .replace(/\s\s+/g, ' ') .replace(/[\r\n]+/g, ' '), - ).toEqual('The changes will be merged into branch'); + ).toEqual('Merges changes into branch'); expect(wrapper.find('a').attributes('href')).toBe('/branch-path'); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index f0fbb1d5851..016b6b2220b 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -269,19 +269,6 @@ describe('ReadyToMerge', () => { }); describe('methods', () => { - describe('updateMergeCommitMessage', () => { - it('should revert flag and change commitMessage', () => { - createComponent(); - - wrapper.vm.updateMergeCommitMessage(true); - - expect(wrapper.vm.commitMessage).toEqual(commitMessageWithDescription); - wrapper.vm.updateMergeCommitMessage(false); - - expect(wrapper.vm.commitMessage).toEqual(commitMessage); - }); - }); - describe('handleMergeButtonClick', () => { const returnPromise = (status) => new Promise((resolve) => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js index 8ead0002950..6abdbd11f5e 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -1,4 +1,4 @@ -import { GlFormCheckbox } from '@gitlab/ui'; +import { GlFormCheckbox, GlLink } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue'; import { SQUASH_BEFORE_MERGE } from '~/vue_merge_request_widget/i18n'; @@ -77,7 +77,7 @@ describe('Squash before merge component', () => { value: false, }); - const aboutLink = wrapper.find('a'); + const aboutLink = wrapper.findComponent(GlLink); expect(aboutLink.exists()).toBe(false); }); @@ -88,7 +88,7 @@ describe('Squash before merge component', () => { helpPath: 'test-path', }); - const aboutLink = wrapper.find('a'); + const aboutLink = wrapper.findComponent(GlLink); expect(aboutLink.exists()).toBe(true); }); @@ -99,7 +99,7 @@ describe('Squash before merge component', () => { helpPath: 'test-path', }); - const aboutLink = wrapper.find('a'); + const aboutLink = wrapper.findComponent(GlLink); expect(aboutLink.attributes('href')).toEqual('test-path'); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index be15e4df66d..0fb0d5b0b68 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -46,7 +46,7 @@ describe('Wip', () => { is_new_mr_data: true, }; - describe('handleRemoveWIP', () => { + describe('handleRemoveDraft', () => { it('should make a request to service and handle response', (done) => { const vm = createComponent(); @@ -59,7 +59,7 @@ describe('Wip', () => { }), ); - vm.handleRemoveWIP(); + vm.handleRemoveDraft(); setImmediate(() => { expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); @@ -84,7 +84,7 @@ describe('Wip', () => { expect(el.innerText).toContain('This merge request is still a draft.'); expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); expect(el.querySelector('button').innerText).toContain('Merge'); - expect(el.querySelector('.js-remove-wip').innerText.replace(/\s\s+/g, ' ')).toContain( + expect(el.querySelector('.js-remove-draft').innerText.replace(/\s\s+/g, ' ')).toContain( 'Mark as ready', ); }); @@ -93,7 +93,7 @@ describe('Wip', () => { vm.mr.removeWIPPath = ''; Vue.nextTick(() => { - expect(el.querySelector('.js-remove-wip')).toEqual(null); + expect(el.querySelector('.js-remove-draft')).toEqual(null); done(); }); }); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index 34a741cf8f2..f0c1da346a1 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -51,7 +51,7 @@ export default { target_branch: 'main', target_project_id: 19, target_project_full_path: '/group2/project2', - merge_request_add_ci_config_path: '/group2/project2/new/pipeline', + merge_request_add_ci_config_path: '/root/group2/project2/-/ci/editor', is_dismissed_suggest_pipeline: false, user_callouts_path: 'some/callout/path', suggest_pipeline_feature_id: 'suggest_pipeline', 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 5aba6982886..550f156d095 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -1,4 +1,4 @@ -import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; +import { GlBadge, GlLink, GlIcon, GlButton, GlDropdown } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; @@ -6,6 +6,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; +import api from '~/api'; import axios from '~/lib/utils/axios_utils'; import { setFaviconOverlay } from '~/lib/utils/favicon'; import notify from '~/lib/utils/notify'; @@ -23,6 +24,8 @@ import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; import mockData from './mock_data'; import testExtension from './test_extension'; +jest.mock('~/api.js'); + jest.mock('~/smart_interval'); jest.mock('~/lib/utils/favicon'); @@ -540,7 +543,7 @@ describe('MrWidgetOptions', () => { nextTick(() => { const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - expect(wrapper.text()).toContain('The source branch will be deleted'); + 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', ); @@ -556,7 +559,7 @@ describe('MrWidgetOptions', () => { nextTick(() => { expect(wrapper.text()).toContain('The source branch has been deleted'); - expect(wrapper.text()).not.toContain('The source branch will be deleted'); + expect(wrapper.text()).not.toContain('Deletes the source branch'); done(); }); @@ -904,6 +907,18 @@ describe('MrWidgetOptions', () => { expect(wrapper.text()).toContain('Test extension summary count: 1'); }); + it('triggers trackRedisHllUserEvent API call', async () => { + await waitForPromises(); + + wrapper + .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') + .trigger('click'); + + await Vue.nextTick(); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_expand_event'); + }); + it('renders full data', async () => { await waitForPromises(); @@ -913,6 +928,10 @@ describe('MrWidgetOptions', () => { await Vue.nextTick(); + expect( + wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(), + ).toBe(false); + const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]'); expect(collapsedSection.exists()).toBe(true); expect(collapsedSection.text()).toContain('Hello world'); @@ -928,6 +947,9 @@ describe('MrWidgetOptions', () => { // Renders a link in the row expect(collapsedSection.find(GlLink).exists()).toBe(true); expect(collapsedSection.find(GlLink).text()).toBe('GitLab.com'); + + expect(collapsedSection.find(GlButton).exists()).toBe(true); + expect(collapsedSection.find(GlButton).text()).toBe('Full report'); }); }); }); diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js index 631d4647b17..fc760f5c5be 100644 --- a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js @@ -15,7 +15,7 @@ describe('getStateKey', () => { branchMissing: false, commitsCount: 2, hasConflicts: false, - workInProgress: false, + draft: false, }; const bound = getStateKey.bind(context); @@ -49,9 +49,9 @@ describe('getStateKey', () => { expect(bound()).toEqual('unresolvedDiscussions'); - context.workInProgress = true; + context.draft = true; - expect(bound()).toEqual('workInProgress'); + expect(bound()).toEqual('draft'); context.onlyAllowMergeIfPipelineSucceeds = true; context.isPipelineFailed = true; @@ -74,6 +74,7 @@ describe('getStateKey', () => { expect(bound()).toEqual('nothingToMerge'); + context.commitsCount = 1; context.branchMissing = true; expect(bound()).toEqual('missingBranch'); @@ -98,7 +99,7 @@ describe('getStateKey', () => { branchMissing: false, commitsCount: 2, hasConflicts: false, - workInProgress: false, + draft: false, }; const bound = getStateKey.bind(context); diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index febcfcd4019..6eb68a1b00d 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -129,7 +129,7 @@ describe('MergeRequestStore', () => { it('should set the add ci config path', () => { store.setPaths({ ...mockData }); - expect(store.mergeRequestAddCiConfigPath).toBe('/group2/project2/new/pipeline'); + expect(store.mergeRequestAddCiConfigPath).toBe('/root/group2/project2/-/ci/editor'); }); it('should set humanAccess=Maintainer when user has that role', () => { diff --git a/spec/frontend/vue_mr_widget/test_extension.js b/spec/frontend/vue_mr_widget/test_extension.js index a29a4d2fb46..65c1bd8473b 100644 --- a/spec/frontend/vue_mr_widget/test_extension.js +++ b/spec/frontend/vue_mr_widget/test_extension.js @@ -3,6 +3,7 @@ import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; export default { name: 'WidgetTestExtension', props: ['targetProjectFullPath'], + expandEvent: 'test_expand_event', computed: { summary({ count, targetProjectFullPath }) { return `Test extension summary count: ${count} & ${targetProjectFullPath}`; @@ -30,6 +31,7 @@ export default { href: 'https://gitlab.com', text: 'GitLab.com', }, + actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], }, ]); }, diff --git a/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js b/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js deleted file mode 100644 index b73f4d6a396..00000000000 --- a/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { GlAlert, GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; - -describe('AlertDetails', () => { - let wrapper; - - function mountComponent(hasManagedPrometheus = false) { - wrapper = mount(AlertDeprecationWarning, { - provide: { - hasManagedPrometheus, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findAlert = () => wrapper.findComponent(GlAlert); - const findLink = () => wrapper.findComponent(GlLink); - - describe('Alert details', () => { - describe('with no manual prometheus', () => { - beforeEach(() => { - mountComponent(); - }); - - it('renders nothing', () => { - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('with manual prometheus', () => { - beforeEach(() => { - mountComponent(true); - }); - - it('renders a deprecation notice', () => { - expect(findAlert().text()).toContain('GitLab-managed Prometheus is deprecated'); - expect(findLink().attributes('href')).toContain( - 'operations/metrics/alerts.html#managed-prometheus-instances', - ); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js new file mode 100644 index 00000000000..f75694bd504 --- /dev/null +++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js @@ -0,0 +1,99 @@ +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { + CONFIRM_DANGER_WARNING, + CONFIRM_DANGER_MODAL_BUTTON, + CONFIRM_DANGER_MODAL_ID, +} from '~/vue_shared/components/confirm_danger/constants'; +import ConfirmDangerModal from '~/vue_shared/components/confirm_danger/confirm_danger_modal.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('Confirm Danger Modal', () => { + const confirmDangerMessage = 'This is a dangerous activity'; + const confirmButtonText = 'Confirm button text'; + const phrase = 'You must construct additional pylons'; + const modalId = CONFIRM_DANGER_MODAL_ID; + + let wrapper; + + const findModal = () => wrapper.findComponent(GlModal); + const findConfirmationPhrase = () => wrapper.findByTestId('confirm-danger-phrase'); + const findConfirmationInput = () => wrapper.findByTestId('confirm-danger-input'); + const findDefaultWarning = () => wrapper.findByTestId('confirm-danger-warning'); + const findAdditionalMessage = () => wrapper.findByTestId('confirm-danger-message'); + const findPrimaryAction = () => findModal().props('actionPrimary'); + const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr]; + + const createComponent = ({ provide = {} } = {}) => + shallowMountExtended(ConfirmDangerModal, { + propsData: { + modalId, + phrase, + }, + provide, + stubs: { GlSprintf }, + }); + + beforeEach(() => { + wrapper = createComponent({ provide: { confirmDangerMessage, confirmButtonText } }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the default warning message', () => { + expect(findDefaultWarning().text()).toBe(CONFIRM_DANGER_WARNING); + }); + + it('renders any additional messages', () => { + expect(findAdditionalMessage().text()).toBe(confirmDangerMessage); + }); + + it('renders the confirm button', () => { + expect(findPrimaryAction().text).toBe(confirmButtonText); + expect(findPrimaryActionAttributes('variant')).toBe('danger'); + }); + + it('renders the correct confirmation phrase', () => { + expect(findConfirmationPhrase().text()).toBe( + `Please type ${phrase} to proceed or close this modal to cancel.`, + ); + }); + + describe('without injected data', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('does not render any additional messages', () => { + expect(findAdditionalMessage().exists()).toBe(false); + }); + + it('renders the default confirm button', () => { + expect(findPrimaryAction().text).toBe(CONFIRM_DANGER_MODAL_BUTTON); + }); + }); + + describe('with a valid confirmation phrase', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('enables the confirm button', async () => { + expect(findPrimaryActionAttributes('disabled')).toBe(true); + + await findConfirmationInput().vm.$emit('input', phrase); + + expect(findPrimaryActionAttributes('disabled')).toBe(false); + }); + + it('emits a `confirm` event when the button is clicked', async () => { + expect(wrapper.emitted('confirm')).toBeUndefined(); + + await findConfirmationInput().vm.$emit('input', phrase); + await findModal().vm.$emit('primary'); + + expect(wrapper.emitted('confirm')).not.toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js new file mode 100644 index 00000000000..220f897c035 --- /dev/null +++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js @@ -0,0 +1,61 @@ +import { GlButton } from '@gitlab/ui'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; +import ConfirmDangerModal from '~/vue_shared/components/confirm_danger/confirm_danger_modal.vue'; +import { CONFIRM_DANGER_MODAL_ID } from '~/vue_shared/components/confirm_danger/constants'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('Confirm Danger Modal', () => { + let wrapper; + + const phrase = 'En Taro Adun'; + const buttonText = 'Click me!'; + const modalId = CONFIRM_DANGER_MODAL_ID; + + const findBtn = () => wrapper.findComponent(GlButton); + const findModal = () => wrapper.findComponent(ConfirmDangerModal); + const findModalProps = () => findModal().props(); + + const createComponent = (props = {}) => + shallowMountExtended(ConfirmDanger, { + propsData: { + buttonText, + phrase, + ...props, + }, + }); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the button', () => { + expect(wrapper.html()).toContain(buttonText); + }); + + it('sets the modal properties', () => { + expect(findModalProps()).toMatchObject({ + modalId, + phrase, + }); + }); + + it('will disable the button if `disabled=true`', () => { + expect(findBtn().attributes('disabled')).toBeUndefined(); + + wrapper = createComponent({ disabled: true }); + + expect(findBtn().attributes('disabled')).toBe('true'); + }); + + it('will emit `confirm` when the modal confirms', () => { + expect(wrapper.emitted('confirm')).toBeUndefined(); + + findModal().vm.$emit('confirm'); + + expect(wrapper.emitted('confirm')).not.toBeUndefined(); + }); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js index 16e7e4dd5cc..f28805471f8 100644 --- a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js +++ b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js @@ -16,6 +16,6 @@ describe('ContentViewer', () => { propsData: { path, fileSize: 1024, type }, }); - expect(wrapper.find(selector).element).toExist(); + expect(wrapper.find(selector).exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js index 3ffb23dc7a0..1397fb0405e 100644 --- a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js @@ -42,7 +42,7 @@ describe('MarkdownViewer', () => { it('renders an animation container while the markdown is loading', () => { createComponent(); - expect(wrapper.find('.animation-container')).toExist(); + expect(wrapper.find('.animation-container').exists()).toBe(true); }); it('renders markdown preview preview renders and loads rendered markdown from server', () => { diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js index 016fe1f131e..b3af5fd3feb 100644 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js @@ -34,6 +34,7 @@ describe('DropdownWidget component', () => { // invokes `show` method of BDropdown used inside GlDropdown. // Context: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54895#note_524281679 jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); + jest.spyOn(findDropdown().vm, 'hide').mockImplementation(); }; beforeEach(() => { @@ -67,10 +68,7 @@ describe('DropdownWidget component', () => { }); it('emits set-option event when clicking on an option', async () => { - wrapper - .findAll('[data-testid="unselected-option"]') - .at(1) - .vm.$emit('click', new Event('click')); + wrapper.findAll('[data-testid="unselected-option"]').at(1).trigger('click'); await wrapper.vm.$nextTick(); expect(wrapper.emitted('set-option')).toEqual([[wrapper.props().options[1]]]); 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 8e931aebfe0..64d15884333 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 @@ -25,6 +25,7 @@ import { tokenValueMilestone, tokenValueMembership, tokenValueConfidential, + tokenValueEmpty, } from './mock_data'; jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ @@ -43,6 +44,7 @@ const createComponent = ({ recentSearchesStorageKey = 'requirements', tokens = mockAvailableTokens, sortOptions, + initialFilterValue = [], showCheckbox = false, checkboxChecked = false, searchInputPlaceholder = 'Filter requirements', @@ -55,6 +57,7 @@ const createComponent = ({ recentSearchesStorageKey, tokens, sortOptions, + initialFilterValue, showCheckbox, checkboxChecked, searchInputPlaceholder, @@ -193,19 +196,27 @@ describe('FilteredSearchBarRoot', () => { describe('watchers', () => { describe('filterValue', () => { - it('emits component event `onFilter` with empty array when `filterValue` is cleared by GlFilteredSearch', () => { + it('emits component event `onFilter` with empty array and false when filter was never selected', () => { + wrapper = createComponent({ initialFilterValue: [tokenValueEmpty] }); wrapper.setData({ initialRender: false, - filterValue: [ - { - type: 'filtered-search-term', - value: { data: '' }, - }, - ], + filterValue: [tokenValueEmpty], + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted('onFilter')[0]).toEqual([[], false]); + }); + }); + + it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', () => { + wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); + wrapper.setData({ + initialRender: false, + filterValue: [tokenValueEmpty], }); return wrapper.vm.$nextTick(() => { - expect(wrapper.emitted('onFilter')[0]).toEqual([[]]); + expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index ae02c554e13..238c5d16db5 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -9,6 +9,7 @@ import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_t import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; +import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; export const mockAuthor1 = { @@ -110,6 +111,18 @@ export const mockIterationToken = { fetchIterations: () => Promise.resolve(), }; +export const mockIterations = [ + { + id: 1, + title: 'Iteration 1', + startDate: '2021-11-05', + dueDate: '2021-11-10', + iterationCadence: { + title: 'Cadence 1', + }, + }, +]; + export const mockLabelToken = { type: 'label_name', icon: 'labels', @@ -132,6 +145,14 @@ export const mockMilestoneToken = { fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; +export const mockReleaseToken = { + type: 'release', + icon: 'rocket', + title: 'Release', + token: ReleaseToken, + fetchReleases: () => Promise.resolve(), +}; + export const mockEpicToken = { type: 'epic_iid', icon: 'clock', @@ -282,6 +303,11 @@ export const tokenValuePlain = { value: { data: 'foo' }, }; +export const tokenValueEmpty = { + type: 'filtered-search-term', + value: { data: '' }, +}; + export const tokenValueEpic = { type: 'epic_iid', value: { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 14fcffd3c50..b29c394e7ae 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -112,6 +112,35 @@ describe('AuthorToken', () => { }); }); + // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 + describe('when there are null users presents', () => { + const mockAuthorsWithNullUser = mockAuthors.concat([null]); + + beforeEach(() => { + jest + .spyOn(wrapper.vm.config, 'fetchAuthors') + .mockResolvedValue({ data: mockAuthorsWithNullUser }); + + getBaseToken().vm.$emit('fetch-suggestions', 'root'); + }); + + describe('when res.data is present', () => { + it('filters the successful response when null values are present', () => { + return waitForPromises().then(() => { + expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); + }); + }); + }); + + describe('when response is an array', () => { + it('filters the successful response when null values are present', () => { + return waitForPromises().then(() => { + expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); + }); + }); + }); + }); + it('calls `createFlash` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js index af90ee93543..44bc16adb97 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js @@ -1,9 +1,13 @@ -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { + GlFilteredSearchToken, + GlFilteredSearchTokenSegment, + GlFilteredSearchSuggestion, +} from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'; -import { mockIterationToken } from '../mock_data'; +import { mockIterationToken, mockIterations } from '../mock_data'; jest.mock('~/flash'); @@ -11,10 +15,16 @@ describe('IterationToken', () => { const id = 123; let wrapper; - const createComponent = ({ config = mockIterationToken, value = { data: '' } } = {}) => + const createComponent = ({ + config = mockIterationToken, + value = { data: '' }, + active = false, + stubs = {}, + provide = {}, + } = {}) => mount(IterationToken, { propsData: { - active: false, + active, config, value, }, @@ -22,13 +32,39 @@ describe('IterationToken', () => { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + ...provide, }, + stubs, }); afterEach(() => { wrapper.destroy(); }); + describe('when iteration cadence feature is available', () => { + beforeEach(async () => { + wrapper = createComponent({ + active: true, + config: { ...mockIterationToken, initialIterations: mockIterations }, + value: { data: 'i' }, + stubs: { Portal: true }, + provide: { + glFeatures: { + iterationCadences: true, + }, + }, + }); + + await wrapper.setData({ loading: false }); + }); + + it('renders iteration start date and due date', () => { + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions.at(3).text()).toContain('Nov 5, 2021 - Nov 10, 2021'); + }); + }); + it('renders iteration value', async () => { wrapper = createComponent({ value: { data: id } }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js new file mode 100644 index 00000000000..b804ff97b82 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js @@ -0,0 +1,78 @@ +import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; +import { mockReleaseToken } from '../mock_data'; + +jest.mock('~/flash'); + +describe('ReleaseToken', () => { + const id = 123; + let wrapper; + + const createComponent = ({ config = mockReleaseToken, value = { data: '' } } = {}) => + mount(ReleaseToken, { + propsData: { + active: false, + config, + value, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: () => 'custom-class', + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders release value', async () => { + wrapper = createComponent({ value: { data: id } }); + await wrapper.vm.$nextTick(); + + const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // `Release` `=` `v1` + expect(tokenSegments.at(2).text()).toBe(id.toString()); + }); + + it('fetches initial values', () => { + const fetchReleasesSpy = jest.fn().mockResolvedValue(); + + wrapper = createComponent({ + config: { ...mockReleaseToken, fetchReleases: fetchReleasesSpy }, + value: { data: id }, + }); + + expect(fetchReleasesSpy).toHaveBeenCalledWith(id); + }); + + it('fetches releases on user input', () => { + const search = 'hello'; + const fetchReleasesSpy = jest.fn().mockResolvedValue(); + + wrapper = createComponent({ + config: { ...mockReleaseToken, fetchReleases: fetchReleasesSpy }, + }); + + wrapper.findComponent(GlFilteredSearchToken).vm.$emit('input', { data: search }); + + expect(fetchReleasesSpy).toHaveBeenCalledWith(search); + }); + + it('renders error message when request fails', async () => { + const fetchReleasesSpy = jest.fn().mockRejectedValue(); + + wrapper = createComponent({ + config: { ...mockReleaseToken, fetchReleases: fetchReleasesSpy }, + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching releases.', + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js index 42f4439df51..b76f475a6fb 100644 --- a/spec/frontend/vue_shared/components/header_ci_component_spec.js +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlAvatarLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import CiIconBadge from '~/vue_shared/components/ci_badge_link.vue'; @@ -18,6 +18,7 @@ describe('Header CI Component', () => { }, time: '2017-05-08T14:57:39.781Z', user: { + id: 1234, web_url: 'path', name: 'Foo', username: 'foobar', @@ -29,7 +30,7 @@ describe('Header CI Component', () => { const findIconBadge = () => wrapper.findComponent(CiIconBadge); const findTimeAgo = () => wrapper.findComponent(TimeagoTooltip); - const findUserLink = () => wrapper.findComponent(GlLink); + const findUserLink = () => wrapper.findComponent(GlAvatarLink); const findSidebarToggleBtn = () => wrapper.findComponent(GlButton); const findActionButtons = () => wrapper.findByTestId('ci-header-action-buttons'); const findHeaderItemText = () => wrapper.findByTestId('ci-header-item-text'); @@ -64,10 +65,6 @@ describe('Header CI Component', () => { expect(findTimeAgo().exists()).toBe(true); }); - it('should render user icon and name', () => { - expect(findUserLink().text()).toContain(defaultProps.user.name); - }); - it('should render sidebar toggle button', () => { expect(findSidebarToggleBtn().exists()).toBe(true); }); @@ -77,6 +74,45 @@ describe('Header CI Component', () => { }); }); + describe('user avatar', () => { + beforeEach(() => { + createComponent({ itemName: 'Pipeline' }); + }); + + it('contains the username', () => { + expect(findUserLink().text()).toContain(defaultProps.user.username); + }); + + it('has the correct data attributes', () => { + expect(findUserLink().attributes()).toMatchObject({ + 'data-user-id': defaultProps.user.id.toString(), + 'data-username': defaultProps.user.username, + 'data-name': defaultProps.user.name, + }); + }); + + describe('with data from GraphQL', () => { + const userId = 1; + + beforeEach(() => { + createComponent({ + itemName: 'Pipeline', + user: { ...defaultProps.user, id: `gid://gitlab/User/${1}` }, + }); + }); + + it('has the correct user id', () => { + expect(findUserLink().attributes('data-user-id')).toBe(userId.toString()); + }); + }); + + describe('with data from REST', () => { + it('has the correct user id', () => { + expect(findUserLink().attributes('data-user-id')).toBe(defaultProps.user.id.toString()); + }); + }); + }); + describe('with item id', () => { beforeEach(() => { createComponent({ itemName: 'Pipeline', itemId: '123' }); diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index 48dacc50923..65f79bab005 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -1,13 +1,27 @@ +import MockAdapter from 'axios-mock-adapter'; import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import initMRPopovers from '~/mr_popover/index'; import createStore from '~/notes/stores'; import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue'; +import axios from '~/lib/utils/axios_utils'; jest.mock('~/mr_popover/index', () => jest.fn()); describe('system note component', () => { let vm; let props; + let mock; + + function createComponent(propsData = {}) { + const store = createStore(); + store.dispatch('setTargetNoteHash', `note_${props.note.id}`); + + vm = mount(IssueSystemNote, { + store, + propsData, + }); + } beforeEach(() => { props = { @@ -27,28 +41,29 @@ describe('system note component', () => { }, }; - const store = createStore(); - store.dispatch('setTargetNoteHash', `note_${props.note.id}`); - - vm = mount(IssueSystemNote, { - store, - propsData: props, - }); + mock = new MockAdapter(axios); }); afterEach(() => { vm.destroy(); + mock.restore(); }); it('should render a list item with correct id', () => { + createComponent(props); + expect(vm.attributes('id')).toEqual(`note_${props.note.id}`); }); it('should render target class is note is target note', () => { + createComponent(props); + expect(vm.classes()).toContain('target'); }); it('should render svg icon', () => { + createComponent(props); + expect(vm.find('.timeline-icon svg').exists()).toBe(true); }); @@ -56,10 +71,31 @@ describe('system note component', () => { // we need to strip them because they break layout of commit lists in system notes: // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png it('removes wrapping paragraph from note HTML', () => { + createComponent(props); + expect(vm.find('.system-note-message').html()).toContain('closed'); }); it('should initMRPopovers onMount', () => { + createComponent(props); + expect(initMRPopovers).toHaveBeenCalled(); }); + + it('renders outdated code lines', async () => { + mock + .onGet('/outdated_line_change_path') + .reply(200, [ + { rich_text: 'console.log', type: 'new', line_code: '123', old_line: null, new_line: 1 }, + ]); + + createComponent({ + note: { ...props.note, outdated_line_change_path: '/outdated_line_change_path' }, + }); + + await vm.find("[data-testid='outdated-lines-change-btn']").trigger('click'); + await waitForPromises(); + + expect(vm.find("[data-testid='outdated-lines']").exists()).toBe(true); + }); }); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index 1ed7844b395..7fdacbe83a2 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -1,6 +1,5 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; -// eslint-disable-next-line import/no-deprecated -import { getJSONFixture } from 'helpers/fixtures'; +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 ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; @@ -13,8 +12,7 @@ describe('ProjectListItem component', () => { let vm; let options; - // eslint-disable-next-line import/no-deprecated - const project = getJSONFixture('static/projects.json')[0]; + const project = JSON.parse(JSON.stringify(mockProjects))[0]; beforeEach(() => { options = { diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js index 1f97d3ff3fa..de5cee846a1 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js @@ -2,8 +2,7 @@ import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import { head } from 'lodash'; import Vue from 'vue'; -// eslint-disable-next-line import/no-deprecated -import { getJSONFixture } from 'helpers/fixtures'; +import mockProjects from 'test_fixtures_static/projects.json'; import { trimText } from 'helpers/text_helper'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; @@ -13,8 +12,7 @@ const localVue = createLocalVue(); describe('ProjectSelector component', () => { let wrapper; let vm; - // eslint-disable-next-line import/no-deprecated - const allProjects = getJSONFixture('static/projects.json'); + const allProjects = mockProjects; const searchResults = allProjects.slice(0, 5); let selected = []; selected = selected.concat(allProjects.slice(0, 3)).concat(allProjects.slice(5, 8)); diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index 75aa3bc7096..b62676b35be 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -1,5 +1,6 @@ import { GlAvatar, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import component from '~/vue_shared/components/registry/title_area.vue'; describe('title area', () => { @@ -7,18 +8,18 @@ describe('title area', () => { const DYNAMIC_SLOT = 'metadata-dynamic-slot'; - const findSubHeaderSlot = () => wrapper.find('[data-testid="sub-header"]'); - const findRightActionsSlot = () => wrapper.find('[data-testid="right-actions"]'); - const findMetadataSlot = (name) => wrapper.find(`[data-testid="${name}"]`); - const findTitle = () => wrapper.find('[data-testid="title"]'); - const findAvatar = () => wrapper.find(GlAvatar); - const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]'); - const findDynamicSlot = () => wrapper.find(`[data-testid="${DYNAMIC_SLOT}`); + const findSubHeaderSlot = () => wrapper.findByTestId('sub-header'); + const findRightActionsSlot = () => wrapper.findByTestId('right-actions'); + const findMetadataSlot = (name) => wrapper.findByTestId(name); + const findTitle = () => wrapper.findByTestId('title'); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findInfoMessages = () => wrapper.findAllByTestId('info-message'); + const findDynamicSlot = () => wrapper.findByTestId(DYNAMIC_SLOT); const findSlotOrderElements = () => wrapper.findAll('[slot-test]'); - const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => { - wrapper = shallowMount(component, { + wrapper = shallowMountExtended(component, { propsData, stubs: { GlSprintf }, slots: { @@ -29,6 +30,12 @@ describe('title area', () => { }); }; + const generateSlotMocks = (names) => + names.reduce((acc, current) => { + acc[current] = `
    `; + return acc; + }, {}); + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -40,6 +47,7 @@ describe('title area', () => { expect(findTitle().text()).toBe('foo'); }); + it('if slot is present uses slot', () => { mountComponent({ slots: { @@ -88,24 +96,21 @@ describe('title area', () => { ${['metadata-foo', 'metadata-bar']} ${['metadata-foo', 'metadata-bar', 'metadata-baz']} `('$slotNames metadata slots', ({ slotNames }) => { - const slotMocks = slotNames.reduce((acc, current) => { - acc[current] = `
    `; - return acc; - }, {}); + const slots = generateSlotMocks(slotNames); it('exist when the slot is present', async () => { - mountComponent({ slots: slotMocks }); + mountComponent({ slots }); - await wrapper.vm.$nextTick(); + await nextTick(); slotNames.forEach((name) => { expect(findMetadataSlot(name).exists()).toBe(true); }); }); it('is/are hidden when metadata-loading is true', async () => { - mountComponent({ slots: slotMocks, propsData: { title: 'foo', metadataLoading: true } }); + mountComponent({ slots, propsData: { title: 'foo', metadataLoading: true } }); - await wrapper.vm.$nextTick(); + await nextTick(); slotNames.forEach((name) => { expect(findMetadataSlot(name).exists()).toBe(false); }); @@ -113,14 +118,20 @@ describe('title area', () => { }); describe('metadata skeleton loader', () => { - it('is hidden when metadata loading is false', () => { - mountComponent(); + const slots = generateSlotMocks(['metadata-foo']); + + it('is hidden when metadata loading is false', async () => { + mountComponent({ slots }); + + await nextTick(); expect(findSkeletonLoader().exists()).toBe(false); }); - it('is shown when metadata loading is true', () => { - mountComponent({ propsData: { metadataLoading: true } }); + it('is shown when metadata loading is true', async () => { + mountComponent({ propsData: { metadataLoading: true }, slots }); + + await nextTick(); expect(findSkeletonLoader().exists()).toBe(true); }); @@ -143,7 +154,7 @@ describe('title area', () => { // updating the slots like we do on line 141 does not cause the updated lifecycle-hook to be triggered wrapper.vm.$forceUpdate(); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findDynamicSlot().exists()).toBe(true); }); @@ -163,7 +174,7 @@ describe('title area', () => { // updating the slots like we do on line 159 does not cause the updated lifecycle-hook to be triggered wrapper.vm.$forceUpdate(); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findSlotOrderElements().at(0).attributes('data-testid')).toBe(DYNAMIC_SLOT); expect(findSlotOrderElements().at(1).attributes('data-testid')).toBe('metadata-foo'); diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 32ef2d27ba7..8536ffed573 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlModal, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; @@ -52,7 +52,7 @@ describe('RunnerInstructionsModal component', () => { const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); const findRegisterCommand = () => wrapper.findByTestId('register-command'); - const createComponent = () => { + const createComponent = ({ props, ...options } = {}) => { const requestHandlers = [ [getRunnerPlatformsQuery, runnerPlatformsHandler], [getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler], @@ -64,9 +64,12 @@ describe('RunnerInstructionsModal component', () => { shallowMount(RunnerInstructionsModal, { propsData: { modalId: 'runner-instructions-modal', + registrationToken: 'MY_TOKEN', + ...props, }, localVue, apolloProvider: fakeApollo, + ...options, }), ); }; @@ -118,18 +121,30 @@ describe('RunnerInstructionsModal component', () => { expect(instructions).toBe(installInstructions); }); - it('register command is shown', () => { + it('register command is shown with a replaced token', () => { const instructions = findRegisterCommand().text(); - expect(instructions).toBe(registerInstructions); + expect(instructions).toBe( + 'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token MY_TOKEN', + ); + }); + + describe('when a register token is not shown', () => { + beforeEach(async () => { + createComponent({ props: { registrationToken: undefined } }); + await nextTick(); + }); + + it('register command is shown without a defined registration token', () => { + const instructions = findRegisterCommand().text(); + + expect(instructions).toBe(registerInstructions); + }); }); }); describe('after a platform and architecture are selected', () => { - const { - installInstructions, - registerInstructions, - } = mockGraphqlInstructionsWindows.data.runnerSetup; + const { installInstructions } = mockGraphqlInstructionsWindows.data.runnerSetup; beforeEach(async () => { runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows); @@ -157,7 +172,9 @@ describe('RunnerInstructionsModal component', () => { it('register command is shown', () => { const command = findRegisterCommand().text(); - expect(command).toBe(registerInstructions); + expect(command).toBe( + './gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token MY_TOKEN', + ); }); }); @@ -217,4 +234,36 @@ describe('RunnerInstructionsModal component', () => { expect(findRegisterCommand().exists()).toBe(false); }); }); + + describe('GlModal API', () => { + const getGlModalStub = (methods) => { + return { + ...GlModal, + methods: { + ...GlModal.methods, + ...methods, + }, + }; + }; + + describe('show()', () => { + let mockShow; + + beforeEach(() => { + mockShow = jest.fn(); + + createComponent({ + stubs: { + GlModal: getGlModalStub({ show: mockShow }), + }, + }); + }); + + it('delegates show()', () => { + wrapper.vm.show(); + + expect(mockShow).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap index 165caea2751..a0f46f07d6a 100644 --- a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap @@ -50,6 +50,7 @@ exports[`Settings Block renders the correct markup 1`] = ` class="settings-content" id="settings_content_3" role="region" + style="display: none;" tabindex="-1" >
    { let wrapper; const mountComponent = (propsData) => { - wrapper = shallowMount(SettingsBlock, { + wrapper = shallowMountExtended(SettingsBlock, { propsData, slots: { title: '
    ', @@ -20,11 +20,13 @@ describe('Settings Block', () => { wrapper.destroy(); }); - const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); - const findTitleSlot = () => wrapper.find('[data-testid="title-slot"]'); - const findDescriptionSlot = () => wrapper.find('[data-testid="description-slot"]'); + const findDefaultSlot = () => wrapper.findByTestId('default-slot'); + const findTitleSlot = () => wrapper.findByTestId('title-slot'); + const findDescriptionSlot = () => wrapper.findByTestId('description-slot'); const findExpandButton = () => wrapper.findComponent(GlButton); - const findSectionTitleButton = () => wrapper.find('[data-testid="section-title-button"]'); + const findSectionTitleButton = () => wrapper.findByTestId('section-title-button'); + // we are using a non js class for this finder because this class determine the component structure + const findSettingsContent = () => wrapper.find('.settings-content'); const expectExpandedState = ({ expanded = true } = {}) => { const settingsExpandButton = findExpandButton(); @@ -62,6 +64,26 @@ describe('Settings Block', () => { expect(findDescriptionSlot().exists()).toBe(true); }); + it('content is hidden before first expansion', async () => { + // this is a regression test for the bug described here: https://gitlab.com/gitlab-org/gitlab/-/issues/331774 + mountComponent(); + + // content is hidden + expect(findDefaultSlot().isVisible()).toBe(false); + + // expand + await findSectionTitleButton().trigger('click'); + + // content is visible + expect(findDefaultSlot().isVisible()).toBe(true); + + // collapse + await findSectionTitleButton().trigger('click'); + + // content is still visible (and we have a closing animation) + expect(findDefaultSlot().isVisible()).toBe(true); + }); + describe('slide animation behaviour', () => { it('is animated by default', () => { mountComponent(); @@ -81,6 +103,20 @@ describe('Settings Block', () => { expect(wrapper.classes('no-animate')).toBe(noAnimatedClass); }, ); + + it('sets the animating class only during the animation', async () => { + mountComponent(); + + expect(wrapper.classes('animating')).toBe(false); + + await findSectionTitleButton().trigger('click'); + + expect(wrapper.classes('animating')).toBe(true); + + await findSettingsContent().trigger('animationend'); + + expect(wrapper.classes('animating')).toBe(false); + }); }); describe('expanded behaviour', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js b/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js index 240d6cb5a34..79e41ed0c9e 100644 --- a/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js @@ -1,36 +1,68 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import collapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -describe('collapsedCalendarIcon', () => { - let vm; - beforeEach(() => { - const CollapsedCalendarIcon = Vue.extend(collapsedCalendarIcon); - vm = mountComponent(CollapsedCalendarIcon, { - containerClass: 'test-class', - text: 'text', - showIcon: false, +import CollapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue'; + +describe('CollapsedCalendarIcon', () => { + let wrapper; + + const defaultProps = { + containerClass: 'test-class', + text: 'text', + tooltipText: 'tooltip text', + showIcon: false, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(CollapsedCalendarIcon, { + propsData: { ...defaultProps, ...props }, + directives: { + GlTooltip: createMockDirective(), + }, }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); }); - it('should add class to container', () => { - expect(vm.$el.classList.contains('test-class')).toEqual(true); + const findGlIcon = () => wrapper.findComponent(GlIcon); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip'); + + it('adds class to container', () => { + expect(wrapper.classes()).toContain(defaultProps.containerClass); + }); + + it('does not render calendar icon when showIcon is false', () => { + expect(findGlIcon().exists()).toBe(false); + }); + + it('renders calendar icon when showIcon is true', () => { + createComponent({ + props: { showIcon: true }, + }); + + expect(findGlIcon().exists()).toBe(true); }); - it('should hide calendar icon if showIcon', () => { - expect(vm.$el.querySelector('[data-testid="calendar-icon"]')).toBeNull(); + it('renders text', () => { + expect(wrapper.text()).toBe(defaultProps.text); }); - it('should render text', () => { - expect(vm.$el.querySelector('span').innerText.trim()).toEqual('text'); + it('renders tooltipText as tooltip', () => { + expect(getTooltip().value).toBe(defaultProps.tooltipText); }); - it('should emit click event when container is clicked', () => { - const click = jest.fn(); - vm.$on('click', click); + it('emits click event when container is clicked', async () => { + wrapper.trigger('click'); - vm.$el.click(); + await wrapper.vm.$nextTick(); - expect(click).toHaveBeenCalled(); + expect(wrapper.emitted('click')[0]).toBeDefined(); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js b/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js index 230442ec547..e72b3bf45c4 100644 --- a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js @@ -1,86 +1,103 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import collapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; - -describe('collapsedGroupedDatePicker', () => { - let vm; - beforeEach(() => { - const CollapsedGroupedDatePicker = Vue.extend(collapsedGroupedDatePicker); - vm = mountComponent(CollapsedGroupedDatePicker, { - showToggleSidebar: true, +import { shallowMount } from '@vue/test-utils'; + +import CollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; +import CollapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue'; + +describe('CollapsedGroupedDatePicker', () => { + let wrapper; + + const defaultProps = { + showToggleSidebar: true, + }; + + const minDate = new Date('07/17/2016'); + const maxDate = new Date('07/17/2017'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(CollapsedGroupedDatePicker, { + propsData: { ...defaultProps, ...props }, }); + }; + + afterEach(() => { + wrapper.destroy(); }); - describe('toggleCollapse events', () => { - beforeEach((done) => { - jest.spyOn(vm, 'toggleSidebar').mockImplementation(() => {}); - vm.minDate = new Date('07/17/2016'); - Vue.nextTick(done); - }); + const findCollapsedCalendarIcon = () => wrapper.findComponent(CollapsedCalendarIcon); + const findAllCollapsedCalendarIcons = () => wrapper.findAllComponents(CollapsedCalendarIcon); + describe('toggleCollapse events', () => { it('should emit when collapsed-calendar-icon is clicked', () => { - vm.$el.querySelector('.sidebar-collapsed-icon').click(); + createComponent(); - expect(vm.toggleSidebar).toHaveBeenCalled(); + findCollapsedCalendarIcon().trigger('click'); + + expect(wrapper.emitted('toggleCollapse')[0]).toBeDefined(); }); }); describe('minDate and maxDate', () => { - beforeEach((done) => { - vm.minDate = new Date('07/17/2016'); - vm.maxDate = new Date('07/17/2017'); - Vue.nextTick(done); - }); - it('should render both collapsed-calendar-icon', () => { - const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); - - expect(icons.length).toEqual(2); - expect(icons[0].innerText.trim()).toEqual('Jul 17 2016'); - expect(icons[1].innerText.trim()).toEqual('Jul 17 2017'); + createComponent({ + props: { + minDate, + maxDate, + }, + }); + + const icons = findAllCollapsedCalendarIcons(); + + expect(icons.length).toBe(2); + expect(icons.at(0).text()).toBe('Jul 17 2016'); + expect(icons.at(1).text()).toBe('Jul 17 2017'); }); }); describe('minDate', () => { - beforeEach((done) => { - vm.minDate = new Date('07/17/2016'); - Vue.nextTick(done); - }); - it('should render minDate in collapsed-calendar-icon', () => { - const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); + createComponent({ + props: { + minDate, + }, + }); + + const icons = findAllCollapsedCalendarIcons(); - expect(icons.length).toEqual(1); - expect(icons[0].innerText.trim()).toEqual('From Jul 17 2016'); + expect(icons.length).toBe(1); + expect(icons.at(0).text()).toBe('From Jul 17 2016'); }); }); describe('maxDate', () => { - beforeEach((done) => { - vm.maxDate = new Date('07/17/2017'); - Vue.nextTick(done); - }); - it('should render maxDate in collapsed-calendar-icon', () => { - const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); - - expect(icons.length).toEqual(1); - expect(icons[0].innerText.trim()).toEqual('Until Jul 17 2017'); + createComponent({ + props: { + maxDate, + }, + }); + const icons = findAllCollapsedCalendarIcons(); + + expect(icons.length).toBe(1); + expect(icons.at(0).text()).toBe('Until Jul 17 2017'); }); }); describe('no dates', () => { + beforeEach(() => { + createComponent(); + }); + it('should render None', () => { - const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); + const icons = findAllCollapsedCalendarIcons(); - expect(icons.length).toEqual(1); - expect(icons[0].innerText.trim()).toEqual('None'); + expect(icons.length).toBe(1); + expect(icons.at(0).text()).toBe('None'); }); it('should have tooltip as `Start and due date`', () => { - const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); + const icons = findAllCollapsedCalendarIcons(); - expect(icons[0].title).toBe('Start and due date'); + expect(icons.at(0).props('tooltipText')).toBe('Start and due date'); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/date_picker_spec.js b/spec/frontend/vue_shared/components/sidebar/date_picker_spec.js index 3221e88192b..263d1e9d947 100644 --- a/spec/frontend/vue_shared/components/sidebar/date_picker_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/date_picker_spec.js @@ -1,3 +1,4 @@ +import { GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import DatePicker from '~/vue_shared/components/pikaday.vue'; import SidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue'; @@ -5,14 +6,8 @@ import SidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue'; describe('SidebarDatePicker', () => { let wrapper; - const mountComponent = (propsData = {}, data = {}) => { - if (wrapper) { - throw new Error('tried to call mountComponent without d'); - } + const createComponent = (propsData = {}, data = {}) => { wrapper = mount(SidebarDatePicker, { - stubs: { - DatePicker: true, - }, propsData, data: () => data, }); @@ -20,87 +15,93 @@ describe('SidebarDatePicker', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); + const findDatePicker = () => wrapper.findComponent(DatePicker); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findEditButton = () => wrapper.find('.title .btn-blank'); + const findRemoveButton = () => wrapper.find('.value-content .btn-blank'); + const findSidebarToggle = () => wrapper.find('.title .gutter-toggle'); + const findValueContent = () => wrapper.find('.value-content'); + it('should emit toggleCollapse when collapsed toggle sidebar is clicked', () => { - mountComponent(); + createComponent(); - wrapper.find('.issuable-sidebar-header .gutter-toggle').element.click(); + wrapper.find('.issuable-sidebar-header .gutter-toggle').trigger('click'); expect(wrapper.emitted('toggleCollapse')).toEqual([[]]); }); it('should render collapsed-calendar-icon', () => { - mountComponent(); + createComponent(); - expect(wrapper.find('.sidebar-collapsed-icon').element).toBeDefined(); + expect(wrapper.find('.sidebar-collapsed-icon').exists()).toBe(true); }); it('should render value when not editing', () => { - mountComponent(); + createComponent(); - expect(wrapper.find('.value-content').element).toBeDefined(); + expect(findValueContent().exists()).toBe(true); }); it('should render None if there is no selectedDate', () => { - mountComponent(); + createComponent(); - expect(wrapper.find('.value-content span').text().trim()).toEqual('None'); + expect(findValueContent().text()).toBe('None'); }); it('should render date-picker when editing', () => { - mountComponent({}, { editing: true }); + createComponent({}, { editing: true }); - expect(wrapper.find(DatePicker).element).toBeDefined(); + expect(findDatePicker().exists()).toBe(true); }); it('should render label', () => { const label = 'label'; - mountComponent({ label }); - expect(wrapper.find('.title').text().trim()).toEqual(label); + createComponent({ label }); + expect(wrapper.find('.title').text()).toBe(label); }); it('should render loading-icon when isLoading', () => { - mountComponent({ isLoading: true }); - expect(wrapper.find('.gl-spinner').element).toBeDefined(); + createComponent({ isLoading: true }); + expect(findLoadingIcon().exists()).toBe(true); }); describe('editable', () => { beforeEach(() => { - mountComponent({ editable: true }); + createComponent({ editable: true }); }); it('should render edit button', () => { - expect(wrapper.find('.title .btn-blank').text().trim()).toEqual('Edit'); + expect(findEditButton().text()).toBe('Edit'); }); it('should enable editing when edit button is clicked', async () => { - wrapper.find('.title .btn-blank').element.click(); + findEditButton().trigger('click'); await wrapper.vm.$nextTick(); - expect(wrapper.vm.editing).toEqual(true); + expect(wrapper.vm.editing).toBe(true); }); }); it('should render date if selectedDate', () => { - mountComponent({ selectedDate: new Date('07/07/2017') }); + createComponent({ selectedDate: new Date('07/07/2017') }); - expect(wrapper.find('.value-content strong').text().trim()).toEqual('Jul 7, 2017'); + expect(wrapper.find('.value-content strong').text()).toBe('Jul 7, 2017'); }); describe('selectedDate and editable', () => { beforeEach(() => { - mountComponent({ selectedDate: new Date('07/07/2017'), editable: true }); + createComponent({ selectedDate: new Date('07/07/2017'), editable: true }); }); it('should render remove button if selectedDate and editable', () => { - expect(wrapper.find('.value-content .btn-blank').text().trim()).toEqual('remove'); + expect(findRemoveButton().text()).toBe('remove'); }); it('should emit saveDate with null when remove button is clicked', () => { - wrapper.find('.value-content .btn-blank').element.click(); + findRemoveButton().trigger('click'); expect(wrapper.emitted('saveDate')).toEqual([[null]]); }); @@ -108,15 +109,15 @@ describe('SidebarDatePicker', () => { describe('showToggleSidebar', () => { beforeEach(() => { - mountComponent({ showToggleSidebar: true }); + createComponent({ showToggleSidebar: true }); }); it('should render toggle-sidebar when showToggleSidebar', () => { - expect(wrapper.find('.title .gutter-toggle').element).toBeDefined(); + expect(findSidebarToggle().exists()).toBe(true); }); it('should emit toggleCollapse when toggle sidebar is clicked', () => { - wrapper.find('.title .gutter-toggle').element.click(); + findSidebarToggle().trigger('click'); expect(wrapper.emitted('toggleCollapse')).toEqual([[]]); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js index 8c1693e8dcc..a7f9391cb5f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js @@ -1,95 +1,74 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import DropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue'; +import { mockCollapsedLabels as mockLabels, mockRegularLabel } from './mock_data'; -import { mockCollapsedLabels as mockLabels } from './mock_data'; - -const createComponent = (labels = mockLabels) => { - const Component = Vue.extend(dropdownValueCollapsedComponent); +describe('DropdownValueCollapsedComponent', () => { + let wrapper; - return mountComponent(Component, { - labels, - }); -}; + const defaultProps = { + labels: [], + }; -describe('DropdownValueCollapsedComponent', () => { - let vm; + const mockManyLabels = [...mockLabels, ...mockLabels, ...mockLabels]; - beforeEach(() => { - vm = createComponent(); - }); + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(DropdownValueCollapsedComponent, { + propsData: { ...defaultProps, ...props }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('computed', () => { - describe('labelsList', () => { - it('returns default text when `labels` prop is empty array', () => { - const vmEmptyLabels = createComponent([]); + const findGlIcon = () => wrapper.findComponent(GlIcon); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip'); - expect(vmEmptyLabels.labelsList).toBe('Labels'); - vmEmptyLabels.$destroy(); - }); - - it('returns labels names separated by coma when `labels` prop has more than one item', () => { - const labels = mockLabels.concat(mockLabels); - const vmMoreLabels = createComponent(labels); + describe('template', () => { + it('renders tags icon element', () => { + createComponent(); - const expectedText = labels.map((label) => label.title).join(', '); + expect(findGlIcon().exists()).toBe(true); + }); - expect(vmMoreLabels.labelsList).toBe(expectedText); - vmMoreLabels.$destroy(); - }); + it('emits onValueClick event on click', async () => { + createComponent(); - it('returns labels names separated by coma with remaining labels count and `and more` phrase when `labels` prop has more than five items', () => { - const mockMoreLabels = Object.assign([], mockLabels); - for (let i = 0; i < 6; i += 1) { - mockMoreLabels.unshift(mockLabels[0]); - } + wrapper.trigger('click'); - const vmMoreLabels = createComponent(mockMoreLabels); + await wrapper.vm.$nextTick(); - const expectedText = `${mockMoreLabels - .slice(0, 5) - .map((label) => label.title) - .join(', ')}, and ${mockMoreLabels.length - 5} more`; + expect(wrapper.emitted('onValueClick')[0]).toBeDefined(); + }); - expect(vmMoreLabels.labelsList).toBe(expectedText); - vmMoreLabels.$destroy(); + describe.each` + scenario | labels | expectedResult | expectedText + ${'`labels` is empty'} | ${[]} | ${'default text'} | ${'Labels'} + ${'`labels` has 1 item'} | ${[mockRegularLabel]} | ${'label name'} | ${'Foo Label'} + ${'`labels` has 2 items'} | ${mockLabels} | ${'comma separated label names'} | ${'Foo Label, Foo::Bar'} + ${'`labels` has more than 5 items'} | ${mockManyLabels} | ${'comma separated label names with "and more" phrase'} | ${'Foo Label, Foo::Bar, Foo Label, Foo::Bar, Foo Label, and 1 more'} + `('when $scenario', ({ labels, expectedResult, expectedText }) => { + beforeEach(() => { + createComponent({ + props: { + labels, + }, + }); }); - it('returns first label name when `labels` prop has only one item present', () => { - const text = mockLabels.map((label) => label.title).join(', '); - - expect(vm.labelsList).toBe(text); + it('renders labels count', () => { + expect(wrapper.text()).toBe(`${labels.length}`); }); - }); - }); - - describe('methods', () => { - describe('handleClick', () => { - it('emits onValueClick event on component', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.handleClick(); - expect(vm.$emit).toHaveBeenCalledWith('onValueClick'); + it(`renders "${expectedResult}" as tooltip`, () => { + expect(getTooltip().value).toBe(expectedText); }); }); }); - - describe('template', () => { - it('renders component container element with tooltip`', () => { - expect(vm.$el.title).toBe(vm.labelsList); - }); - - it('renders tags icon element', () => { - expect(vm.$el.querySelector('[data-testid="labels-icon"]')).not.toBeNull(); - }); - - it('renders labels count', () => { - expect(vm.$el.querySelector('span').innerText.trim()).toBe(`${vm.labels.length}`); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index d9b7cd5afa2..a60e6f52862 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -1,3 +1,4 @@ +import { cloneDeep } from 'lodash'; import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations'; @@ -153,47 +154,40 @@ describe('LabelsSelect Mutations', () => { }); describe(`${types.UPDATE_SELECTED_LABELS}`, () => { - let labels; - - beforeEach(() => { - labels = [ - { id: 1, title: 'scoped' }, - { id: 2, title: 'scoped::one', set: false }, - { id: 3, title: 'scoped::test', set: true }, - { id: 4, title: '' }, - ]; - }); - - it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { - const updatedLabelIds = [2]; - const state = { - labels, - }; - mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] }); - - state.labels.forEach((label) => { - if (updatedLabelIds.includes(label.id)) { - expect(label.touched).toBe(true); - expect(label.set).toBe(true); - } + const labels = [ + { id: 1, title: 'scoped' }, + { id: 2, title: 'scoped::label::one', set: false }, + { id: 3, title: 'scoped::label::two', set: false }, + { id: 4, title: 'scoped::label::three', set: true }, + { id: 5, title: 'scoped::one', set: false }, + { id: 6, title: 'scoped::two', set: false }, + { id: 7, title: 'scoped::three', set: true }, + { id: 8, title: '' }, + ]; + + it.each` + label | labelGroupIds + ${labels[0]} | ${[]} + ${labels[1]} | ${[labels[2], labels[3]]} + ${labels[2]} | ${[labels[1], labels[3]]} + ${labels[3]} | ${[labels[1], labels[2]]} + ${labels[4]} | ${[labels[5], labels[6]]} + ${labels[5]} | ${[labels[4], labels[6]]} + ${labels[6]} | ${[labels[4], labels[5]]} + ${labels[7]} | ${[]} + `('updates `touched` and `set` props for $label.title', ({ label, labelGroupIds }) => { + const state = { labels: cloneDeep(labels) }; + + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: label.id }] }); + + expect(state.labels[label.id - 1]).toMatchObject({ + touched: true, + set: !labels[label.id - 1].set, }); - }); - describe('when label is scoped', () => { - it('unsets the currently selected scoped label and sets the current label', () => { - const state = { - labels, - }; - mutations[types.UPDATE_SELECTED_LABELS](state, { - labels: [{ id: 2, title: 'scoped::one' }], - }); - - expect(state.labels).toEqual([ - { id: 1, title: 'scoped' }, - { id: 2, title: 'scoped::one', set: true, touched: true }, - { id: 3, title: 'scoped::test', set: false }, - { id: 4, title: '' }, - ]); + labelGroupIds.forEach((l) => { + expect(state.labels[l.id - 1].touched).toBeFalsy(); + expect(state.labels[l.id - 1].set).toBe(false); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index 8931584e12c..bf873f9162b 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -5,8 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; -import { labelsQueries } from '~/sidebar/constants'; +import { workspaceLabelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; import { @@ -50,11 +49,12 @@ describe('DropdownContentsCreateView', () => { const createComponent = ({ mutationHandler = createLabelSuccessHandler, - issuableType = IssuableType.Issue, + labelCreateType = 'project', + workspaceType = 'project', } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); mockApollo.clients.defaultClient.cache.writeQuery({ - query: labelsQueries[issuableType].workspaceQuery, + query: workspaceLabelsQueries[workspaceType].query, data: workspaceLabelsQueryResponse.data, variables: { fullPath: '', @@ -66,8 +66,10 @@ describe('DropdownContentsCreateView', () => { localVue, apolloProvider: mockApollo, propsData: { - issuableType, fullPath: '', + attrWorkspacePath: '', + labelCreateType, + workspaceType, }, }); }; @@ -128,9 +130,11 @@ describe('DropdownContentsCreateView', () => { it('emits a `hideCreateView` event on Cancel button click', () => { createComponent(); - findCancelButton().vm.$emit('click'); + const event = { stopPropagation: jest.fn() }; + findCancelButton().vm.$emit('click', event); expect(wrapper.emitted('hideCreateView')).toHaveLength(1); + expect(event.stopPropagation).toHaveBeenCalled(); }); describe('when label title and selected color are set', () => { @@ -174,7 +178,7 @@ describe('DropdownContentsCreateView', () => { }); it('calls a mutation with `groupPath` variable on the epic', () => { - createComponent({ issuableType: IssuableType.Epic }); + createComponent({ labelCreateType: 'group', workspaceType: 'group' }); fillLabelAttributes(); findCreateButton().vm.$emit('click'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index fac3331a2b8..2980409fdce 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -10,7 +10,6 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; @@ -43,6 +42,7 @@ describe('DropdownContentsLabelsView', () => { initialState = mockConfig, queryHandler = successfulQueryHandler, injected = {}, + searchKey = '', } = {}) => { const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]); @@ -56,7 +56,9 @@ describe('DropdownContentsLabelsView', () => { propsData: { ...initialState, localSelectedLabels, - issuableType: IssuableType.Issue, + searchKey, + labelCreateType: 'project', + workspaceType: 'project', }, stubs: { GlSearchBoxByType, @@ -68,7 +70,6 @@ describe('DropdownContentsLabelsView', () => { wrapper.destroy(); }); - const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findLabels = () => wrapper.findAllComponents(LabelItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findObserver = () => wrapper.findComponent(GlIntersectionObserver); @@ -81,12 +82,6 @@ describe('DropdownContentsLabelsView', () => { } describe('when loading labels', () => { - it('renders disabled search input field', async () => { - createComponent(); - await makeObserverAppear(); - expect(findSearchInput().props('disabled')).toBe(true); - }); - it('renders loading icon', async () => { createComponent(); await makeObserverAppear(); @@ -107,10 +102,6 @@ describe('DropdownContentsLabelsView', () => { await waitForPromises(); }); - it('renders enabled search input field', async () => { - expect(findSearchInput().props('disabled')).toBe(false); - }); - it('does not render loading icon', async () => { expect(findLoadingIcon().exists()).toBe(false); }); @@ -132,9 +123,9 @@ describe('DropdownContentsLabelsView', () => { }, }, }), + searchKey: '123', }); await makeObserverAppear(); - findSearchInput().vm.$emit('input', '123'); await waitForPromises(); await nextTick(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js index 36704ac5ef3..8bcef347c96 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js @@ -4,6 +4,8 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; +import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue'; +import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue'; import { mockLabels } from './mock_data'; @@ -26,7 +28,7 @@ const GlDropdownStub = { describe('DropdownContent', () => { let wrapper; - const createComponent = ({ props = {}, injected = {}, data = {} } = {}) => { + const createComponent = ({ props = {}, data = {} } = {}) => { wrapper = shallowMount(DropdownContents, { propsData: { labelsCreateTitle: 'test', @@ -37,8 +39,10 @@ describe('DropdownContent', () => { footerManageLabelTitle: 'manage', dropdownButtonText: 'Labels', variant: 'sidebar', - issuableType: 'issue', fullPath: 'test', + workspaceType: 'project', + labelCreateType: 'project', + attrWorkspacePath: 'path', ...props, }, data() { @@ -46,11 +50,6 @@ describe('DropdownContent', () => { ...data, }; }, - provide: { - allowLabelCreate: true, - labelsManagePath: 'foo/bar', - ...injected, - }, stubs: { GlDropdown: GlDropdownStub, }, @@ -63,13 +62,10 @@ describe('DropdownContent', () => { const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView); const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeader); + const findDropdownFooter = () => wrapper.findComponent(DropdownFooter); const findDropdown = () => wrapper.findComponent(GlDropdownStub); - const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); - const findDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]'); - const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); - const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]'); - it('calls dropdown `show` method on `isVisible` prop change', async () => { createComponent(); await wrapper.setProps({ @@ -136,6 +132,16 @@ describe('DropdownContent', () => { expect(findDropdownHeader().exists()).toBe(true); }); + it('sets searchKey for labels view on input event from header', async () => { + createComponent(); + + expect(wrapper.vm.searchKey).toEqual(''); + findDropdownHeader().vm.$emit('input', '123'); + await nextTick(); + + expect(findLabelsView().props('searchKey')).toEqual('123'); + }); + describe('Create view', () => { beforeEach(() => { createComponent({ data: { showDropdownContentsCreateView: true } }); @@ -149,16 +155,8 @@ describe('DropdownContent', () => { expect(findDropdownFooter().exists()).toBe(false); }); - it('does not render create label button', () => { - expect(findCreateLabelButton().exists()).toBe(false); - }); - - it('renders go back button', () => { - expect(findGoBackButton().exists()).toBe(true); - }); - - it('changes the view to Labels view on back button click', async () => { - findGoBackButton().vm.$emit('click', new MouseEvent('click')); + it('changes the view to Labels view on `toggleDropdownContentsCreateView` event', async () => { + findDropdownHeader().vm.$emit('toggleDropdownContentsCreateView'); await nextTick(); expect(findCreateView().exists()).toBe(false); @@ -198,32 +196,5 @@ describe('DropdownContent', () => { expect(findDropdownFooter().exists()).toBe(true); }); - - it('does not render go back button', () => { - expect(findGoBackButton().exists()).toBe(false); - }); - - it('does not render create label button if `allowLabelCreate` is false', () => { - createComponent({ injected: { allowLabelCreate: false } }); - - expect(findCreateLabelButton().exists()).toBe(false); - }); - - describe('when `allowLabelCreate` is true', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders create label button', () => { - expect(findCreateLabelButton().exists()).toBe(true); - }); - - it('changes the view to Create on create label button click', async () => { - findCreateLabelButton().trigger('click'); - - await nextTick(); - expect(findLabelsView().exists()).toBe(false); - }); - }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js new file mode 100644 index 00000000000..0508a059195 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue'; + +describe('DropdownFooter', () => { + let wrapper; + + const createComponent = ({ props = {}, injected = {} } = {}) => { + wrapper = shallowMount(DropdownFooter, { + propsData: { + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', + ...props, + }, + provide: { + allowLabelCreate: true, + labelsManagePath: 'foo/bar', + ...injected, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); + + describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render create label button if `allowLabelCreate` is false', () => { + createComponent({ injected: { allowLabelCreate: false } }); + + expect(findCreateLabelButton().exists()).toBe(false); + }); + + describe('when `allowLabelCreate` is true', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders create label button', () => { + expect(findCreateLabelButton().exists()).toBe(true); + }); + + it('emits `toggleDropdownContentsCreateView` event on create label button click', async () => { + findCreateLabelButton().trigger('click'); + + await nextTick(); + expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js new file mode 100644 index 00000000000..592559ef305 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js @@ -0,0 +1,75 @@ +import { GlSearchBoxByType } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue'; + +describe('DropdownHeader', () => { + let wrapper; + + const createComponent = ({ + showDropdownContentsCreateView = false, + labelsFetchInProgress = false, + } = {}) => { + wrapper = extendedWrapper( + shallowMount(DropdownHeader, { + propsData: { + showDropdownContentsCreateView, + labelsFetchInProgress, + labelsCreateTitle: 'Create label', + labelsListTitle: 'Select label', + searchKey: '', + }, + stubs: { + GlSearchBoxByType, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findGoBackButton = () => wrapper.findByTestId('go-back-button'); + + beforeEach(() => { + createComponent(); + }); + + describe('Create view', () => { + beforeEach(() => { + createComponent({ showDropdownContentsCreateView: true }); + }); + + it('renders go back button', () => { + expect(findGoBackButton().exists()).toBe(true); + }); + + it('does not render search input field', async () => { + expect(findSearchInput().exists()).toBe(false); + }); + }); + + describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render go back button', () => { + expect(findGoBackButton().exists()).toBe(false); + }); + + it.each` + labelsFetchInProgress | disabled + ${true} | ${true} + ${false} | ${false} + `( + 'when labelsFetchInProgress is $labelsFetchInProgress, renders search input with disabled prop to $disabled', + ({ labelsFetchInProgress, disabled }) => { + createComponent({ labelsFetchInProgress }); + expect(findSearchInput().props('disabled')).toBe(disabled); + }, + ); + }); +}); 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 b5441d711a5..d4203528874 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 @@ -41,6 +41,8 @@ describe('LabelsSelectRoot', () => { propsData: { ...config, issuableType: IssuableType.Issue, + labelCreateType: 'project', + workspaceType: 'project', }, stubs: { SidebarEditableItem, @@ -121,11 +123,11 @@ describe('LabelsSelectRoot', () => { }); }); - it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event', async () => { + it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', async () => { const label = { id: 'gid://gitlab/ProjectLabel/1' }; - createComponent(); + createComponent({ config: { ...mockConfig, iid: undefined } }); findDropdownContents().vm.$emit('setLabels', [label]); - expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[label]]]); + expect(wrapper.emitted('updateSelectedLabels')).toEqual([[{ labels: [label] }]]); }); }); 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 23a457848d9..5c5bf5f2187 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 @@ -40,12 +40,12 @@ export const mockConfig = { labelsListTitle: 'Assign labels', labelsCreateTitle: 'Create label', variant: 'sidebar', - selectedLabels: [mockRegularLabel, mockScopedLabel], labelsSelectInProgress: false, labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', footerCreateLabelTitle: 'create', footerManageLabelTitle: 'manage', + attrWorkspacePath: 'test', }; export const mockSuggestedColors = { @@ -80,6 +80,7 @@ export const createLabelSuccessfulResponse = { color: '#dc143c', description: null, title: 'ewrwrwer', + textColor: '#000000', __typename: 'Label', }, errors: [], @@ -91,6 +92,7 @@ export const createLabelSuccessfulResponse = { export const workspaceLabelsQueryResponse = { data: { workspace: { + id: 'gid://gitlab/Project/126', labels: { nodes: [ { @@ -98,12 +100,14 @@ export const workspaceLabelsQueryResponse = { description: null, id: 'gid://gitlab/ProjectLabel/1', title: 'Label1', + textColor: '#000000', }, { color: '#2f7b2e', description: null, id: 'gid://gitlab/ProjectLabel/2', title: 'Label2', + textColor: '#000000', }, ], }, @@ -123,6 +127,7 @@ export const issuableLabelsQueryResponse = { description: null, id: 'gid://gitlab/ProjectLabel/1', title: 'Label1', + textColor: '#000000', }, ], }, diff --git a/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js b/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js index f1c3e8a1ddc..a6c9bda1aa2 100644 --- a/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js @@ -1,31 +1,45 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import toggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; - -describe('toggleSidebar', () => { - let vm; - beforeEach(() => { - const ToggleSidebar = Vue.extend(toggleSidebar); - vm = mountComponent(ToggleSidebar, { - collapsed: true, +import { GlButton } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; + +import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; + +describe('ToggleSidebar', () => { + let wrapper; + + const defaultProps = { + collapsed: true, + }; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(ToggleSidebar, { + propsData: { ...defaultProps, ...props }, }); + }; + + afterEach(() => { + wrapper.destroy(); }); + const findGlButton = () => wrapper.findComponent(GlButton); + it('should render the "chevron-double-lg-left" icon when collapsed', () => { - expect(vm.$el.querySelector('[data-testid="chevron-double-lg-left-icon"]')).not.toBeNull(); + createComponent(); + + expect(findGlButton().props('icon')).toBe('chevron-double-lg-left'); }); it('should render the "chevron-double-lg-right" icon when expanded', async () => { - vm.collapsed = false; - await Vue.nextTick(); - expect(vm.$el.querySelector('[data-testid="chevron-double-lg-right-icon"]')).not.toBeNull(); + createComponent({ props: { collapsed: false } }); + + expect(findGlButton().props('icon')).toBe('chevron-double-lg-right'); }); - it('should emit toggle event when button clicked', () => { - const toggle = jest.fn(); - vm.$on('toggle', toggle); - vm.$el.click(); + it('should emit toggle event when button clicked', async () => { + createComponent({ mountFn: mount }); + + findGlButton().trigger('click'); + await wrapper.vm.$nextTick(); - expect(toggle).toHaveBeenCalled(); + expect(wrapper.emitted('toggle')[0]).toBeDefined(); }); }); diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js index a92f058f311..78abb89e7b8 100644 --- a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js +++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js @@ -82,7 +82,7 @@ describe('User deletion obstacles list', () => { createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); const msg = findObstacles().text(); - expect(msg).toContain(`in Project ${projectName}`); + expect(msg).toContain(`in project ${projectName}`); expect(findLinks().at(1).attributes('href')).toBe(projectUrl); }); }, diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js index c361f934e59..ef61462a3c5 100644 --- a/spec/frontend/whats_new/utils/notification_spec.js +++ b/spec/frontend/whats_new/utils/notification_spec.js @@ -29,7 +29,7 @@ describe('~/whats_new/utils/notification', () => { subject(); - expect(findNotificationCountEl()).toExist(); + expect(findNotificationCountEl()).not.toBe(null); expect(notificationEl.classList).toContain('with-notifications'); }); @@ -38,11 +38,11 @@ describe('~/whats_new/utils/notification', () => { notificationEl.classList.add('with-notifications'); localStorage.setItem('display-whats-new-notification', 'version-digest'); - expect(findNotificationCountEl()).toExist(); + expect(findNotificationCountEl()).not.toBe(null); subject(); - expect(findNotificationCountEl()).not.toExist(); + expect(findNotificationCountEl()).toBe(null); expect(notificationEl.classList).not.toContain('with-notifications'); }); }); diff --git a/spec/frontend/work_items/components/app_spec.js b/spec/frontend/work_items/components/app_spec.js new file mode 100644 index 00000000000..95034085493 --- /dev/null +++ b/spec/frontend/work_items/components/app_spec.js @@ -0,0 +1,24 @@ +import { shallowMount } from '@vue/test-utils'; +import App from '~/work_items/components/app.vue'; + +describe('Work Items Application', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(App, { + stubs: { + 'router-view': true, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a component', () => { + createComponent(); + + expect(wrapper.exists()).toBe(true); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js new file mode 100644 index 00000000000..efb4aa2feb2 --- /dev/null +++ b/spec/frontend/work_items/mock_data.js @@ -0,0 +1,17 @@ +export const workItemQueryResponse = { + workItem: { + __typename: 'WorkItem', + id: '1', + type: 'FEATURE', + widgets: { + __typename: 'WorkItemWidgetConnection', + nodes: [ + { + __typename: 'TitleWidget', + type: 'TITLE', + contentText: 'Test', + }, + ], + }, + }, +}; diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js new file mode 100644 index 00000000000..64d02baed36 --- /dev/null +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -0,0 +1,70 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; +import { workItemQueryResponse } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const WORK_ITEM_ID = '1'; + +describe('Work items root component', () => { + let wrapper; + let fakeApollo; + + const findTitle = () => wrapper.find('[data-testid="title"]'); + + const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => { + fakeApollo = createMockApollo(); + fakeApollo.clients.defaultClient.cache.writeQuery({ + query: workItemQuery, + variables: { + id: WORK_ITEM_ID, + }, + data: queryResponse, + }); + + wrapper = shallowMount(WorkItemsRoot, { + propsData: { + id: WORK_ITEM_ID, + }, + localVue, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('renders the title if title is in the widgets list', () => { + createComponent(); + + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe('Test'); + }); + + it('does not render the title if title is not in the widgets list', () => { + const queryResponse = { + workItem: { + ...workItemQueryResponse.workItem, + widgets: { + __typename: 'WorkItemWidgetConnection', + nodes: [ + { + __typename: 'SomeOtherWidget', + type: 'OTHER', + contentText: 'Test', + }, + ], + }, + }, + }; + createComponent({ queryResponse }); + + expect(findTitle().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js new file mode 100644 index 00000000000..0a57eab753f --- /dev/null +++ b/spec/frontend/work_items/router_spec.js @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils'; +import App from '~/work_items/components/app.vue'; +import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; +import { createRouter } from '~/work_items/router'; + +describe('Work items router', () => { + let wrapper; + + const createComponent = async (routeArg) => { + const router = createRouter('/work_item'); + if (routeArg !== undefined) { + await router.push(routeArg); + } + + wrapper = mount(App, { + router, + }); + }; + + afterEach(() => { + wrapper.destroy(); + window.location.hash = ''; + }); + + it('renders work item on `/1` route', async () => { + await createComponent('/1'); + + expect(wrapper.find(WorkItemsRoot).exists()).toBe(true); + }); +}); diff --git a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb index 21a1aa2741a..0f05504d4f2 100644 --- a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb +++ b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb @@ -29,7 +29,7 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) - .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end end @@ -45,7 +45,7 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) - .with_message('Feature disabled') + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") end end @@ -97,5 +97,5 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do end end - specify { expect(described_class).to require_graphql_authorizations(:admin_contact) } + specify { expect(described_class).to require_graphql_authorizations(:admin_crm_contact) } end diff --git a/spec/graphql/mutations/customer_relations/contacts/update_spec.rb b/spec/graphql/mutations/customer_relations/contacts/update_spec.rb index 93bc6f53cf9..4f59de194fd 100644 --- a/spec/graphql/mutations/customer_relations/contacts/update_spec.rb +++ b/spec/graphql/mutations/customer_relations/contacts/update_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Update do let(:last_name) { 'Smith' } let(:email) { 'ls@gitlab.com' } let(:description) { 'VIP' } - let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" } + let(:does_not_exist_or_no_permission) { Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR } let(:contact) { create(:contact, group: group) } let(:attributes) do { @@ -65,11 +65,11 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Update do it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) - .with_message('Feature disabled') + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") end end end end - specify { expect(described_class).to require_graphql_authorizations(:admin_contact) } + specify { expect(described_class).to require_graphql_authorizations(:admin_crm_contact) } end diff --git a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb index 738a8d724ab..9be0f5d4289 100644 --- a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb @@ -30,7 +30,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) - .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end end @@ -46,7 +46,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) - .with_message('Feature disabled') + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") end end @@ -69,5 +69,5 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do end end - specify { expect(described_class).to require_graphql_authorizations(:admin_organization) } + specify { expect(described_class).to require_graphql_authorizations(:admin_crm_organization) } end diff --git a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb index 0bc6f184fe3..e3aa8eafe0c 100644 --- a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do let(:name) { 'GitLab' } let(:default_rate) { 1000.to_f } let(:description) { 'VIP' } - let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" } + let(:does_not_exist_or_no_permission) { Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR } let(:organization) { create(:organization, group: group) } let(:attributes) do { @@ -63,11 +63,11 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) - .with_message('Feature disabled') + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") end end end end - specify { expect(described_class).to require_graphql_authorizations(:admin_organization) } + specify { expect(described_class).to require_graphql_authorizations(:admin_crm_organization) } end diff --git a/spec/graphql/mutations/discussions/toggle_resolve_spec.rb b/spec/graphql/mutations/discussions/toggle_resolve_spec.rb index 8c11279a80a..2041b86d6e7 100644 --- a/spec/graphql/mutations/discussions/toggle_resolve_spec.rb +++ b/spec/graphql/mutations/discussions/toggle_resolve_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Mutations::Discussions::ToggleResolve do it 'raises an error if the resource is not accessible to the user' do expect { subject }.to raise_error( Gitlab::Graphql::Errors::ResourceNotAvailable, - "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR ) end end @@ -41,7 +41,7 @@ RSpec.describe Mutations::Discussions::ToggleResolve do it 'raises an error' do expect { subject }.to raise_error( Gitlab::Graphql::Errors::ResourceNotAvailable, - "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR ) 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 2715a908f85..48e55828a6b 100644 --- a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb +++ b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb @@ -60,7 +60,7 @@ RSpec.describe Mutations::Environments::CanaryIngress::Update do let(:user) { reporter } it 'raises an error' do - expect { subject }.to raise_error("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + expect { subject }.to raise_error(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end end end diff --git a/spec/graphql/mutations/merge_requests/set_wip_spec.rb b/spec/graphql/mutations/merge_requests/set_wip_spec.rb deleted file mode 100644 index fae9c4f7fe0..00000000000 --- a/spec/graphql/mutations/merge_requests/set_wip_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Mutations::MergeRequests::SetWip do - let(:merge_request) { create(:merge_request) } - let(:user) { create(:user) } - - subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } - - specify { expect(described_class).to require_graphql_authorizations(:update_merge_request) } - - describe '#resolve' do - let(:wip) { true } - let(:mutated_merge_request) { subject[:merge_request] } - - subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, wip: wip) } - - it_behaves_like 'permission level for merge request mutation is correctly verified' - - context 'when the user can update the merge request' do - before do - merge_request.project.add_developer(user) - end - - it 'returns the merge request as a wip' do - expect(mutated_merge_request).to eq(merge_request) - expect(mutated_merge_request).to be_work_in_progress - expect(subject[:errors]).to be_empty - end - - it 'returns errors merge request could not be updated' do - # Make the merge request invalid - merge_request.allow_broken = true - merge_request.update!(source_project: nil) - - expect(subject[:errors]).not_to be_empty - end - - context 'when passing wip as false' do - let(:wip) { false } - - it 'removes `wip` from the title' do - merge_request.update!(title: "WIP: working on it") - - expect(mutated_merge_request).not_to be_work_in_progress - end - - it 'does not do anything if the title did not start with wip' do - expect(mutated_merge_request).not_to be_work_in_progress - end - end - end - end -end diff --git a/spec/graphql/mutations/notes/reposition_image_diff_note_spec.rb b/spec/graphql/mutations/notes/reposition_image_diff_note_spec.rb index e78f755d5c7..39794a070c6 100644 --- a/spec/graphql/mutations/notes/reposition_image_diff_note_spec.rb +++ b/spec/graphql/mutations/notes/reposition_image_diff_note_spec.rb @@ -29,7 +29,7 @@ RSpec.describe Mutations::Notes::RepositionImageDiffNote do it 'raises an error if the resource is not accessible to the user' do expect { subject }.to raise_error( Gitlab::Graphql::Errors::ResourceNotAvailable, - "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR ) end end diff --git a/spec/graphql/mutations/releases/delete_spec.rb b/spec/graphql/mutations/releases/delete_spec.rb index d97f839ce87..9934aea0031 100644 --- a/spec/graphql/mutations/releases/delete_spec.rb +++ b/spec/graphql/mutations/releases/delete_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Mutations::Releases::Delete do shared_examples 'unauthorized or not found error' do it 'raises a Gitlab::Graphql::Errors::ResourceNotAvailable error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, "The resource that you are attempting to access does not exist or you don't have permission to perform this action") + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end end diff --git a/spec/graphql/mutations/releases/update_spec.rb b/spec/graphql/mutations/releases/update_spec.rb index 5ee63ac4dc2..9fae703b85a 100644 --- a/spec/graphql/mutations/releases/update_spec.rb +++ b/spec/graphql/mutations/releases/update_spec.rb @@ -232,7 +232,7 @@ RSpec.describe Mutations::Releases::Update do let(:mutation_arguments) { super().merge(project_path: 'not/a/real/path') } it 'raises an error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, "The resource that you are attempting to access does not exist or you don't have permission to perform this action") + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end end end @@ -242,7 +242,7 @@ RSpec.describe Mutations::Releases::Update do let(:current_user) { reporter } it 'raises an error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, "The resource that you are attempting to access does not exist or you don't have permission to perform this action") + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end end end diff --git a/spec/graphql/mutations/security/ci_configuration/configure_sast_iac_spec.rb b/spec/graphql/mutations/security/ci_configuration/configure_sast_iac_spec.rb new file mode 100644 index 00000000000..f16d504a4ae --- /dev/null +++ b/spec/graphql/mutations/security/ci_configuration/configure_sast_iac_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Security::CiConfiguration::ConfigureSastIac do + include GraphqlHelpers + + let(:service) { ::Security::CiConfiguration::SastIacCreateService } + + subject { resolve(described_class, args: { project_path: project.full_path }, ctx: { current_user: user }) } + + include_examples 'graphql mutations security ci configuration' +end diff --git a/spec/graphql/resolvers/concerns/resolves_groups_spec.rb b/spec/graphql/resolvers/concerns/resolves_groups_spec.rb new file mode 100644 index 00000000000..bfbbae29e92 --- /dev/null +++ b/spec/graphql/resolvers/concerns/resolves_groups_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResolvesGroups do + include GraphqlHelpers + include AfterNextHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:groups) { create_pair(:group) } + + let_it_be(:resolver) do + Class.new(Resolvers::BaseResolver) do + include ResolvesGroups + type Types::GroupType, null: true + end + end + + let_it_be(:query_type) do + query_factory do |query| + query.field :groups, + Types::GroupType.connection_type, + null: true, + resolver: resolver + end + end + + let_it_be(:lookahead_fields) do + <<~FIELDS + contacts { nodes { id } } + containerRepositoriesCount + customEmoji { nodes { id } } + fullPath + organizations { nodes { id } } + path + dependencyProxyBlobCount + dependencyProxyBlobs { nodes { fileName } } + dependencyProxyImageCount + dependencyProxyImageTtlPolicy { enabled } + dependencyProxySetting { enabled } + FIELDS + end + + it 'avoids N+1 queries on the fields marked with lookahead' do + group_ids = groups.map(&:id) + + allow_next(resolver).to receive(:resolve_groups).and_return(Group.id_in(group_ids)) + # Prevent authorization queries from affecting the test. + allow(Ability).to receive(:allowed?).and_return(true) + + single_group_query = ActiveRecord::QueryRecorder.new do + data = query_groups(limit: 1) + expect(data.size).to eq(1) + end + + multi_group_query = -> { + data = query_groups(limit: 2) + expect(data.size).to eq(2) + } + + expect { multi_group_query.call }.not_to exceed_query_limit(single_group_query) + end + + def query_groups(limit:) + query_string = "{ groups(first: #{limit}) { nodes { id #{lookahead_fields} } } }" + + data = execute_query(query_type, graphql: query_string) + + graphql_dig_at(data, :data, :groups, :nodes) + end +end diff --git a/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb b/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb index 865e892b12d..3fcfa967452 100644 --- a/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb +++ b/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb @@ -20,23 +20,37 @@ RSpec.describe ResolvesPipelines do let_it_be(:project) { create(:project, :private) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } let_it_be(:failed_pipeline) { create(:ci_pipeline, :failed, project: project) } + let_it_be(:success_pipeline) { create(:ci_pipeline, :success, project: project) } let_it_be(:ref_pipeline) { create(:ci_pipeline, project: project, ref: 'awesome-feature') } let_it_be(:sha_pipeline) { create(:ci_pipeline, project: project, sha: 'deadbeef') } + let_it_be(:all_pipelines) do + [ + pipeline, + failed_pipeline, + success_pipeline, + ref_pipeline, + sha_pipeline + ] + end before do project.add_developer(current_user) end - it { is_expected.to have_graphql_arguments(:status, :ref, :sha, :source) } + it { is_expected.to have_graphql_arguments(:status, :scope, :ref, :sha, :source) } it 'finds all pipelines' do - expect(resolve_pipelines).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline) + expect(resolve_pipelines).to contain_exactly(*all_pipelines) end it 'allows filtering by status' do expect(resolve_pipelines(status: 'failed')).to contain_exactly(failed_pipeline) end + it 'allows filtering by scope' do + expect(resolve_pipelines(scope: 'finished')).to contain_exactly(failed_pipeline, success_pipeline) + end + it 'allows filtering by ref' do expect(resolve_pipelines(ref: 'awesome-feature')).to contain_exactly(ref_pipeline) end @@ -54,7 +68,7 @@ RSpec.describe ResolvesPipelines do end it 'does not filter by source' do - expect(resolve_pipelines(source: 'web')).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline, source_pipeline) + expect(resolve_pipelines(source: 'web')).to contain_exactly(*all_pipelines, source_pipeline) end end @@ -64,7 +78,7 @@ RSpec.describe ResolvesPipelines do end it 'returns all the pipelines' do - expect(resolve_pipelines).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline, source_pipeline) + expect(resolve_pipelines).to contain_exactly(*all_pipelines, source_pipeline) end end end diff --git a/spec/graphql/resolvers/group_issues_resolver_spec.rb b/spec/graphql/resolvers/group_issues_resolver_spec.rb index 463cdca699b..e17429560ac 100644 --- a/spec/graphql/resolvers/group_issues_resolver_spec.rb +++ b/spec/graphql/resolvers/group_issues_resolver_spec.rb @@ -29,15 +29,72 @@ RSpec.describe Resolvers::GroupIssuesResolver do describe '#resolve' do it 'finds all group issues' do - result = resolve(described_class, obj: group, ctx: { current_user: current_user }) - - expect(result).to contain_exactly(issue1, issue2, issue3) + expect(resolve_issues).to contain_exactly(issue1, issue2, issue3) end it 'finds all group and subgroup issues' do - result = resolve(described_class, obj: group, args: { include_subgroups: true }, ctx: { current_user: current_user }) + result = resolve_issues(include_subgroups: true) expect(result).to contain_exactly(issue1, issue2, issue3, subissue1, subissue2, subissue3) end + + it 'returns issues without the specified issue_type' do + result = resolve_issues(not: { types: ['issue'] }) + + expect(result).to contain_exactly(issue1) + end + + context 'confidential issues' do + let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) } + let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) } + + context "when user is allowed to view confidential issues" do + it 'returns all viewable issues by default' do + expect(resolve_issues).to contain_exactly(issue1, issue2, issue3, confidential_issue1, confidential_issue2) + end + + context 'filtering for confidential issues' do + it 'returns only the non-confidential issues for the group when filter is set to false' do + expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3) + end + + it "returns only the confidential issues for the group when filter is set to true" do + expect(resolve_issues({ confidential: true })).to contain_exactly(confidential_issue1, confidential_issue2) + end + end + end + + context "when user is not allowed to see confidential issues" do + before do + group.add_guest(current_user) + end + + it 'returns all viewable issues by default' do + expect(resolve_issues).to contain_exactly(issue1, issue2, issue3) + end + + context 'filtering for confidential issues' do + it 'does not return the confidential issues when filter is set to false' do + expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3) + end + + it 'does not return the confidential issues when filter is set to true' do + expect(resolve_issues({ confidential: true })).to be_empty + end + end + end + end + + context 'release_tag filter' do + it 'returns an error when trying to filter by negated release_tag' do + expect do + resolve_issues(not: { release_tag: ['v1.0'] }) + end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'releaseTag filter is not allowed when parent is a group.') + end + end + end + + def resolve_issues(args = {}, context = { current_user: current_user }) + resolve(described_class, obj: group, args: args, ctx: context) end end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 9897e697009..3c892214aaf 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -26,14 +26,7 @@ RSpec.describe Resolvers::IssuesResolver do expect(described_class).to have_nullable_graphql_type(Types::IssueType.connection_type) end - shared_context 'filtering for confidential issues' do - let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) } - let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) } - end - context "with a project" do - let(:obj) { project } - before_all do project.add_developer(current_user) project.add_reporter(reporter) @@ -112,6 +105,54 @@ RSpec.describe Resolvers::IssuesResolver do end end + describe 'filter by release' do + let_it_be(:milestone1) { create(:milestone, project: project, start_date: 1.day.from_now, title: 'Version 1') } + let_it_be(:milestone2) { create(:milestone, project: project, start_date: 1.day.from_now, title: 'Version 2') } + let_it_be(:milestone3) { create(:milestone, project: project, start_date: 1.day.from_now, title: 'Version 3') } + let_it_be(:release1) { create(:release, tag: 'v1.0', milestones: [milestone1], project: project) } + let_it_be(:release2) { create(:release, tag: 'v2.0', milestones: [milestone2], project: project) } + let_it_be(:release3) { create(:release, tag: 'v3.0', milestones: [milestone3], project: project) } + let_it_be(:release_issue1) { create(:issue, project: project, milestone: milestone1) } + let_it_be(:release_issue2) { create(:issue, project: project, milestone: milestone2) } + let_it_be(:release_issue3) { create(:issue, project: project, milestone: milestone3) } + + describe 'filter by release_tag' do + it 'returns all issues associated with the specified tags' do + expect(resolve_issues(release_tag: [release1.tag, release3.tag])).to contain_exactly(release_issue1, release_issue3) + end + + context 'when release_tag_wildcard_id is also provided' do + it 'raises a mutually eclusive argument error' do + expect do + resolve_issues(release_tag: [release1.tag], release_tag_wildcard_id: 'ANY') + end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [releaseTag, releaseTagWildcardId] arguments is allowed at the same time.') + end + end + end + + describe 'filter by negated release_tag' do + it 'returns all issues not associated with the specified tags' do + expect(resolve_issues(not: { release_tag: [release1.tag, release3.tag] })).to contain_exactly(release_issue2) + end + end + + describe 'filter by release_tag_wildcard_id' do + subject { resolve_issues(release_tag_wildcard_id: wildcard_id) } + + context 'when filtering by ANY' do + let(:wildcard_id) { 'ANY' } + + it { is_expected.to contain_exactly(release_issue1, release_issue2, release_issue3) } + end + + context 'when filtering by NONE' do + let(:wildcard_id) { 'NONE' } + + it { is_expected.to contain_exactly(issue1, issue2) } + end + end + end + it 'filters by two assignees' do assignee2 = create(:user) issue2.update!(assignees: [assignee, assignee2]) @@ -230,7 +271,8 @@ RSpec.describe Resolvers::IssuesResolver do end context 'confidential issues' do - include_context 'filtering for confidential issues' + let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) } + let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) } context "when user is allowed to view confidential issues" do it 'returns all viewable issues by default' do @@ -561,64 +603,6 @@ RSpec.describe Resolvers::IssuesResolver do end end - context "with a group" do - let(:obj) { group } - - before do - group.add_developer(current_user) - end - - describe '#resolve' do - it 'finds all group issues' do - expect(resolve_issues).to contain_exactly(issue1, issue2, issue3) - end - - it 'returns issues without the specified issue_type' do - expect(resolve_issues({ not: { types: ['issue'] } })).to contain_exactly(issue1) - end - - context "confidential issues" do - include_context 'filtering for confidential issues' - - context "when user is allowed to view confidential issues" do - it 'returns all viewable issues by default' do - expect(resolve_issues).to contain_exactly(issue1, issue2, issue3, confidential_issue1, confidential_issue2) - end - - context 'filtering for confidential issues' do - it 'returns only the non-confidential issues for the group when filter is set to false' do - expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3) - end - - it "returns only the confidential issues for the group when filter is set to true" do - expect(resolve_issues({ confidential: true })).to contain_exactly(confidential_issue1, confidential_issue2) - end - end - end - - context "when user is not allowed to see confidential issues" do - before do - group.add_guest(current_user) - end - - it 'returns all viewable issues by default' do - expect(resolve_issues).to contain_exactly(issue1, issue2, issue3) - end - - context 'filtering for confidential issues' do - it 'does not return the confidential issues when filter is set to false' do - expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3) - end - - it 'does not return the confidential issues when filter is set to true' do - expect(resolve_issues({ confidential: true })).to be_empty - end - end - end - end - end - end - context "when passing a non existent, batch loaded project" do let!(:project) do BatchLoader::GraphQL.for("non-existent-path").batch do |_fake_paths, loader, _| @@ -626,8 +610,6 @@ RSpec.describe Resolvers::IssuesResolver do end end - let(:obj) { project } - it "returns nil without breaking" do expect(resolve_issues(iids: ["don't", "break"])).to be_empty end @@ -648,6 +630,6 @@ RSpec.describe Resolvers::IssuesResolver do end def resolve_issues(args = {}, context = { current_user: current_user }) - resolve(described_class, obj: obj, args: args, ctx: context) + resolve(described_class, obj: project, args: args, ctx: context) end end diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb index a897acf7eba..a931b0a3f77 100644 --- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb +++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb @@ -218,6 +218,54 @@ RSpec.describe Resolvers::MergeRequestsResolver do end end + context 'with created_after and created_before arguments' do + before do + merge_request_1.update!(created_at: 4.days.ago) + end + + let(:all_mrs) do + [merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6, merge_request_with_milestone] + end + + it 'returns merge requests created within a given period' do + result = resolve_mr(project, created_after: 5.days.ago, created_before: 2.days.ago) + + expect(result).to contain_exactly( + merge_request_1 + ) + end + + it 'returns some values filtered with created_before' do + result = resolve_mr(project, created_before: 1.day.ago) + + expect(result).to contain_exactly(merge_request_1) + end + + it 'returns some values filtered with created_after' do + result = resolve_mr(project, created_after: 3.days.ago) + + expect(result).to match_array(all_mrs - [merge_request_1]) + end + + it 'does not return anything for dates (even in the future) not matching any MRs' do + result = resolve_mr(project, created_after: 5.days.from_now) + + expect(result).to be_empty + end + + it 'does not return anything for dates not matching any MRs' do + result = resolve_mr(project, created_before: 15.days.ago) + + expect(result).to be_empty + end + + it 'does not return any values for an impossible set' do + result = resolve_mr(project, created_after: 5.days.ago, created_before: 6.days.ago) + + expect(result).to be_empty + end + end + context 'with milestone argument' do it 'filters merge requests by milestone title' do result = resolve_mr(project, milestone_title: milestone.title) diff --git a/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb b/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb index 75b9be7dfe7..c6d8c518fb7 100644 --- a/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb +++ b/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb @@ -90,7 +90,10 @@ RSpec.describe Resolvers::Projects::JiraProjectsResolver do end it 'raises failure error' do - expect { resolve_jira_projects }.to raise_error('An error occurred while requesting data from Jira: Some failure. Check your Jira integration configuration and try again.') + config_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/configure') + docs_link_start = '
    '.html_safe % { url: config_docs_link_url } + error_message = 'An error occurred while requesting data from Jira: Some failure. Check your %{docs_link_start}Jira integration configuration and try again.' % { docs_link_start: docs_link_start } + expect { resolve_jira_projects }.to raise_error(error_message) end end end diff --git a/spec/graphql/resolvers/timelog_resolver_spec.rb b/spec/graphql/resolvers/timelog_resolver_spec.rb index f45f528fe7e..9b3f555071e 100644 --- a/spec/graphql/resolvers/timelog_resolver_spec.rb +++ b/spec/graphql/resolvers/timelog_resolver_spec.rb @@ -11,6 +11,8 @@ RSpec.describe Resolvers::TimelogResolver do let_it_be(:issue) { create(:issue, project: project) } let_it_be(:error_class) { Gitlab::Graphql::Errors::ArgumentError } + let(:timelogs) { resolve_timelogs(**args) } + specify do expect(described_class).to have_non_null_graphql_type(::Types::TimelogType.connection_type) end @@ -24,8 +26,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_time: 6.days.ago, end_time: 2.days.ago.noon } } it 'finds all timelogs within given dates' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1) end @@ -33,8 +33,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { {} } it 'finds all timelogs' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1, timelog2, timelog3) end end @@ -43,8 +41,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_time: 2.days.ago.noon } } it 'finds timelogs after the start_time' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog2) end end @@ -53,8 +49,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { end_time: 2.days.ago.noon } } it 'finds timelogs before the end_time' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1, timelog3) end end @@ -63,8 +57,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_time: 6.days.ago, end_date: 2.days.ago } } it 'finds timelogs until the end of day of end_date' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1, timelog2) end end @@ -73,8 +65,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_date: 6.days.ago, end_time: 2.days.ago.noon } } it 'finds all timelogs within start_date and end_time' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1) end end @@ -96,7 +86,7 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_time: 6.days.ago, start_date: 6.days.ago } } it 'returns correct error' do - expect { resolve_timelogs(**args) } + expect { timelogs } .to raise_error(error_class, /Provide either a start date or time, but not both/) end end @@ -105,7 +95,7 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { end_time: 2.days.ago, end_date: 2.days.ago } } it 'returns correct error' do - expect { resolve_timelogs(**args) } + expect { timelogs } .to raise_error(error_class, /Provide either an end date or time, but not both/) end end @@ -114,14 +104,14 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_time: 2.days.ago, end_time: 6.days.ago } } it 'returns correct error' do - expect { resolve_timelogs(**args) } + expect { timelogs } .to raise_error(error_class, /Start argument must be before End argument/) end end end end - shared_examples "with a group" do + shared_examples 'with a group' do let_it_be(:short_time_ago) { 5.days.ago.beginning_of_day } let_it_be(:medium_time_ago) { 15.days.ago.beginning_of_day } @@ -141,8 +131,6 @@ RSpec.describe Resolvers::TimelogResolver do end it 'finds all timelogs within given dates' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1) end @@ -150,8 +138,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_date: short_time_ago } } it 'finds timelogs until the end of day of end_date' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1, timelog2) end end @@ -160,8 +146,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { end_date: medium_time_ago } } it 'finds timelogs until the end of day of end_date' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog3) end end @@ -170,8 +154,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_time: short_time_ago, end_date: short_time_ago } } it 'finds timelogs until the end of day of end_date' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1, timelog2) end end @@ -180,8 +162,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_date: short_time_ago, end_time: short_time_ago.noon } } it 'finds all timelogs within start_date and end_time' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1) end end @@ -191,7 +171,7 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_time: short_time_ago, start_date: short_time_ago } } it 'returns correct error' do - expect { resolve_timelogs(**args) } + expect { timelogs } .to raise_error(error_class, /Provide either a start date or time, but not both/) end end @@ -200,7 +180,7 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { end_time: short_time_ago, end_date: short_time_ago } } it 'returns correct error' do - expect { resolve_timelogs(**args) } + expect { timelogs } .to raise_error(error_class, /Provide either an end date or time, but not both/) end end @@ -209,14 +189,14 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { { start_time: short_time_ago, end_time: medium_time_ago } } it 'returns correct error' do - expect { resolve_timelogs(**args) } + expect { timelogs } .to raise_error(error_class, /Start argument must be before End argument/) end end end end - shared_examples "with a user" do + shared_examples 'with a user' do let_it_be(:short_time_ago) { 5.days.ago.beginning_of_day } let_it_be(:medium_time_ago) { 15.days.ago.beginning_of_day } @@ -228,20 +208,18 @@ RSpec.describe Resolvers::TimelogResolver do let_it_be(:timelog3) { create(:merge_request_timelog, merge_request: merge_request, user: current_user) } it 'blah' do - timelogs = resolve_timelogs(**args) - expect(timelogs).to contain_exactly(timelog1, timelog3) end end - context "on a project" do + context 'on a project' do let(:object) { project } let(:extra_args) { {} } it_behaves_like 'with a project' end - context "with a project filter" do + context 'with a project filter' do let(:object) { nil } let(:extra_args) { { project_id: project.to_global_id } } @@ -285,8 +263,6 @@ RSpec.describe Resolvers::TimelogResolver do let(:extra_args) { {} } it 'pagination returns `default_max_page_size` and sets `has_next_page` true' do - timelogs = resolve_timelogs(**args) - expect(timelogs.items.count).to be(100) expect(timelogs.has_next_page).to be(true) end @@ -298,7 +274,7 @@ RSpec.describe Resolvers::TimelogResolver do let(:extra_args) { {} } it 'returns correct error' do - expect { resolve_timelogs(**args) } + expect { timelogs } .to raise_error(error_class, /Provide at least one argument/) end end diff --git a/spec/graphql/resolvers/topics_resolver_spec.rb b/spec/graphql/resolvers/topics_resolver_spec.rb new file mode 100644 index 00000000000..3ff1dabc927 --- /dev/null +++ b/spec/graphql/resolvers/topics_resolver_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::TopicsResolver do + include GraphqlHelpers + + describe '#resolve' do + let!(:topic1) { create(:topic, name: 'GitLab', total_projects_count: 1) } + let!(:topic2) { create(:topic, name: 'git', total_projects_count: 2) } + let!(:topic3) { create(:topic, name: 'topic3', total_projects_count: 3) } + + it 'finds all topics' do + expect(resolve_topics).to eq([topic3, topic2, topic1]) + end + + context 'with search' do + it 'searches environment by name' do + expect(resolve_topics(search: 'git')).to eq([topic2, topic1]) + end + + context 'when the search term does not match any topic' do + it 'is empty' do + expect(resolve_topics(search: 'nonsense')).to be_empty + end + end + end + end + + def resolve_topics(args = {}) + resolve(described_class, args: args) + end +end diff --git a/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb b/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb index 31cf94aef44..bfb6958e327 100644 --- a/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb +++ b/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb @@ -50,7 +50,7 @@ RSpec.describe GitlabSchema.types['AlertManagementPrometheusIntegration'] do describe 'a group integration' do let_it_be(:group) { create(:group) } - let_it_be(:integration) { create(:prometheus_integration, project: nil, group: group) } + let_it_be(:integration) { create(:prometheus_integration, :group, group: group) } # Since it is impossible to authorize the parent here, given that the # project is nil, all fields should be redacted: diff --git a/spec/graphql/types/ci/job_artifact_type_spec.rb b/spec/graphql/types/ci/job_artifact_type_spec.rb index d4dc5ef214d..58b5f9cfcb7 100644 --- a/spec/graphql/types/ci/job_artifact_type_spec.rb +++ b/spec/graphql/types/ci/job_artifact_type_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['CiJobArtifact'] do it 'has the correct fields' do - expected_fields = [:download_path, :file_type] + expected_fields = [:download_path, :file_type, :name] expect(described_class).to have_graphql_fields(*expected_fields) end diff --git a/spec/graphql/types/ci/pipeline_scope_enum_spec.rb b/spec/graphql/types/ci/pipeline_scope_enum_spec.rb new file mode 100644 index 00000000000..9dc6e5c6fae --- /dev/null +++ b/spec/graphql/types/ci/pipeline_scope_enum_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::PipelineScopeEnum do + it 'exposes all pipeline scopes' do + expect(described_class.values.keys).to contain_exactly( + *::Ci::PipelinesFinder::ALLOWED_SCOPES.keys.map(&:to_s) + ) + end +end diff --git a/spec/graphql/types/ci/pipeline_status_enum_spec.rb b/spec/graphql/types/ci/pipeline_status_enum_spec.rb new file mode 100644 index 00000000000..2d6683c6384 --- /dev/null +++ b/spec/graphql/types/ci/pipeline_status_enum_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::PipelineStatusEnum do + it 'exposes all pipeline states' do + expect(described_class.values.keys).to contain_exactly( + *::Ci::Pipeline.all_state_names.map(&:to_s).map(&:upcase) + ) + end +end diff --git a/spec/graphql/types/ci/pipeline_type_spec.rb b/spec/graphql/types/ci/pipeline_type_spec.rb index 8c849114cf6..58724524785 100644 --- a/spec/graphql/types/ci/pipeline_type_spec.rb +++ b/spec/graphql/types/ci/pipeline_type_spec.rb @@ -12,8 +12,8 @@ RSpec.describe Types::Ci::PipelineType do id iid sha before_sha complete status detailed_status config_source duration queued_duration coverage created_at updated_at started_at finished_at committed_at - stages user retryable cancelable jobs source_job job downstream - upstream path project active user_permissions warnings commit_path uses_needs + stages user retryable cancelable jobs source_job job job_artifacts downstream + upstream path project active user_permissions warnings commit commit_path uses_needs test_report_summary test_suite ref ] diff --git a/spec/graphql/types/commit_type_spec.rb b/spec/graphql/types/commit_type_spec.rb index b43693e5804..2f74ce81761 100644 --- a/spec/graphql/types/commit_type_spec.rb +++ b/spec/graphql/types/commit_type_spec.rb @@ -9,7 +9,7 @@ RSpec.describe GitlabSchema.types['Commit'] do it 'contains attributes related to commit' do expect(described_class).to have_graphql_fields( - :id, :sha, :short_id, :title, :description, :description_html, :message, :title_html, :authored_date, + :id, :sha, :short_id, :title, :full_title, :full_title_html, :description, :description_html, :message, :title_html, :authored_date, :author_name, :author_gravatar, :author, :web_url, :web_path, :pipelines, :signature_html ) diff --git a/spec/graphql/types/customer_relations/contact_type_spec.rb b/spec/graphql/types/customer_relations/contact_type_spec.rb index a51ee705fb0..bb447f405b6 100644 --- a/spec/graphql/types/customer_relations/contact_type_spec.rb +++ b/spec/graphql/types/customer_relations/contact_type_spec.rb @@ -7,5 +7,5 @@ RSpec.describe GitlabSchema.types['CustomerRelationsContact'] do it { expect(described_class.graphql_name).to eq('CustomerRelationsContact') } it { expect(described_class).to have_graphql_fields(fields) } - it { expect(described_class).to require_graphql_authorizations(:read_contact) } + it { expect(described_class).to require_graphql_authorizations(:read_crm_contact) } end diff --git a/spec/graphql/types/customer_relations/organization_type_spec.rb b/spec/graphql/types/customer_relations/organization_type_spec.rb index 2562748477c..93844df1239 100644 --- a/spec/graphql/types/customer_relations/organization_type_spec.rb +++ b/spec/graphql/types/customer_relations/organization_type_spec.rb @@ -7,5 +7,5 @@ RSpec.describe GitlabSchema.types['CustomerRelationsOrganization'] do it { expect(described_class.graphql_name).to eq('CustomerRelationsOrganization') } it { expect(described_class).to have_graphql_fields(fields) } - it { expect(described_class).to require_graphql_authorizations(:read_organization) } + it { expect(described_class).to require_graphql_authorizations(:read_crm_organization) } end diff --git a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb index 18cc89adfcb..b251ca63c4f 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[ - file_name image_name size created_at updated_at digest + id file_name image_name size created_at updated_at digest ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/evidence_type_spec.rb b/spec/graphql/types/evidence_type_spec.rb index 92134e74d51..be85724eac5 100644 --- a/spec/graphql/types/evidence_type_spec.rb +++ b/spec/graphql/types/evidence_type_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['ReleaseEvidence'] do - it { expect(described_class).to require_graphql_authorizations(:download_code) } + specify { expect(described_class).to require_graphql_authorizations(:read_release_evidence) } it 'has the expected fields' do expected_fields = %w[ diff --git a/spec/graphql/types/merge_request_review_state_enum_spec.rb b/spec/graphql/types/merge_request_review_state_enum_spec.rb index 486e1c4f502..407a1ae3c1f 100644 --- a/spec/graphql/types/merge_request_review_state_enum_spec.rb +++ b/spec/graphql/types/merge_request_review_state_enum_spec.rb @@ -12,6 +12,10 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewState'] do 'UNREVIEWED' => have_attributes( description: 'The merge request is unreviewed.', value: 'unreviewed' + ), + 'ATTENTION_REQUESTED' => have_attributes( + description: 'The merge request is attention_requested.', + value: 'attention_requested' ) ) end diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb index bc3ccb0d9ba..b17b7c32289 100644 --- a/spec/graphql/types/merge_request_type_spec.rb +++ b/spec/graphql/types/merge_request_type_spec.rb @@ -18,7 +18,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do notes discussions user_permissions id iid title title_html description description_html state created_at updated_at source_project target_project project project_id source_project_id target_project_id source_branch - target_branch work_in_progress draft merge_when_pipeline_succeeds diff_head_sha + target_branch draft merge_when_pipeline_succeeds diff_head_sha merge_commit_sha user_notes_count user_discussions_count should_remove_source_branch diff_refs diff_stats diff_stats_summary force_remove_source_branch diff --git a/spec/graphql/types/mutation_type_spec.rb b/spec/graphql/types/mutation_type_spec.rb index c1a5c93c85b..95d835c88cf 100644 --- a/spec/graphql/types/mutation_type_spec.rb +++ b/spec/graphql/types/mutation_type_spec.rb @@ -3,14 +3,6 @@ require 'spec_helper' RSpec.describe Types::MutationType do - it 'is expected to have the deprecated MergeRequestSetWip' do - field = get_field('MergeRequestSetWip') - - expect(field).to be_present - expect(field.deprecation_reason).to be_present - expect(field.resolver).to eq(Mutations::MergeRequests::SetWip) - end - it 'is expected to have the MergeRequestSetDraft' do expect(described_class).to have_graphql_mutation(Mutations::MergeRequests::SetDraft) end diff --git a/spec/graphql/types/packages/helm/dependency_type_spec.rb b/spec/graphql/types/packages/helm/dependency_type_spec.rb new file mode 100644 index 00000000000..2047205275f --- /dev/null +++ b/spec/graphql/types/packages/helm/dependency_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageHelmDependencyType'] do + it { expect(described_class.graphql_name).to eq('PackageHelmDependencyType') } + + it 'includes helm dependency fields' do + expected_fields = %w[ + name version repository condition tags enabled import_values alias + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/packages/helm/file_metadatum_type_spec.rb b/spec/graphql/types/packages/helm/file_metadatum_type_spec.rb new file mode 100644 index 00000000000..b7bcd6213b4 --- /dev/null +++ b/spec/graphql/types/packages/helm/file_metadatum_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['HelmFileMetadata'] do + it { expect(described_class.graphql_name).to eq('HelmFileMetadata') } + + it 'includes helm file metadatum fields' do + expected_fields = %w[ + created_at updated_at channel metadata + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/packages/helm/maintainer_type_spec.rb b/spec/graphql/types/packages/helm/maintainer_type_spec.rb new file mode 100644 index 00000000000..9ad51427d42 --- /dev/null +++ b/spec/graphql/types/packages/helm/maintainer_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageHelmMaintainerType'] do + it { expect(described_class.graphql_name).to eq('PackageHelmMaintainerType') } + + it 'includes helm maintainer fields' do + expected_fields = %w[ + name email url + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/packages/helm/metadata_type_spec.rb b/spec/graphql/types/packages/helm/metadata_type_spec.rb new file mode 100644 index 00000000000..04639450d9a --- /dev/null +++ b/spec/graphql/types/packages/helm/metadata_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageHelmMetadataType'] do + it { expect(described_class.graphql_name).to eq('PackageHelmMetadataType') } + + it 'includes helm json fields' do + expected_fields = %w[ + name home sources version description keywords maintainers icon apiVersion condition tags appVersion deprecated annotations kubeVersion dependencies type + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 45a718683be..4f205e861dd 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -34,7 +34,7 @@ RSpec.describe GitlabSchema.types['Project'] do container_repositories container_repositories_count pipeline_analytics squash_read_only sast_ci_configuration cluster_agent cluster_agents agent_configurations - ci_template timelogs + ci_template timelogs merge_commit_template ] expect(described_class).to include_graphql_fields(*expected_fields) @@ -296,6 +296,8 @@ RSpec.describe GitlabSchema.types['Project'] do :last, :merged_after, :merged_before, + :created_after, + :created_before, :author_username, :assignee_username, :reviewer_username, diff --git a/spec/graphql/types/projects/topic_type_spec.rb b/spec/graphql/types/projects/topic_type_spec.rb new file mode 100644 index 00000000000..01c19e111be --- /dev/null +++ b/spec/graphql/types/projects/topic_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Projects::TopicType do + specify { expect(described_class.graphql_name).to eq('Topic') } + + specify do + expect(described_class).to have_graphql_fields( + :id, + :name, + :description, + :description_html, + :avatar_url + ) + end +end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index 14ef03a64f9..49f0980bd08 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -28,6 +28,7 @@ RSpec.describe GitlabSchema.types['Query'] do runners timelogs board_list + topics ] expect(described_class).to have_graphql_fields(*expected_fields).at_least diff --git a/spec/graphql/types/release_links_type_spec.rb b/spec/graphql/types/release_links_type_spec.rb index 38c38d58baa..e77c4e3ddd1 100644 --- a/spec/graphql/types/release_links_type_spec.rb +++ b/spec/graphql/types/release_links_type_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['ReleaseLinks'] do - it { expect(described_class).to require_graphql_authorizations(:download_code) } + it { expect(described_class).to require_graphql_authorizations(:read_release) } it 'has the expected fields' do expected_fields = %w[ @@ -18,4 +18,46 @@ RSpec.describe GitlabSchema.types['ReleaseLinks'] do expect(described_class).to include_graphql_fields(*expected_fields) end + + context 'individual field authorization' do + def fetch_authorizations(field_name) + described_class.fields.dig(field_name).instance_variable_get(:@authorize) + end + + describe 'openedMergeRequestsUrl' do + it 'has valid authorization' do + expect(fetch_authorizations('openedMergeRequestsUrl')).to include(:download_code) + end + end + + describe 'mergedMergeRequestsUrl' do + it 'has valid authorization' do + expect(fetch_authorizations('mergedMergeRequestsUrl')).to include(:download_code) + end + end + + describe 'closedMergeRequestsUrl' do + it 'has valid authorization' do + expect(fetch_authorizations('closedMergeRequestsUrl')).to include(:download_code) + end + end + + describe 'openedIssuesUrl' do + it 'has valid authorization' do + expect(fetch_authorizations('openedIssuesUrl')).to include(:download_code) + end + end + + describe 'closedIssuesUrl' do + it 'has valid authorization' do + expect(fetch_authorizations('closedIssuesUrl')).to include(:download_code) + end + end + + describe 'editUrl' do + it 'has valid authorization' do + expect(fetch_authorizations('editUrl')).to include(:update_release) + end + end + end end diff --git a/spec/graphql/types/repository/blob_type_spec.rb b/spec/graphql/types/repository/blob_type_spec.rb index beab4dcebc2..7f37237f355 100644 --- a/spec/graphql/types/repository/blob_type_spec.rb +++ b/spec/graphql/types/repository/blob_type_spec.rb @@ -23,6 +23,7 @@ RSpec.describe Types::Repository::BlobType do :stored_externally, :raw_path, :replace_path, + :pipeline_editor_path, :simple_viewer, :rich_viewer, :plain_data, diff --git a/spec/graphql/types/user_merge_request_interaction_type_spec.rb b/spec/graphql/types/user_merge_request_interaction_type_spec.rb index f424c9200ab..1eaaa0c23d0 100644 --- a/spec/graphql/types/user_merge_request_interaction_type_spec.rb +++ b/spec/graphql/types/user_merge_request_interaction_type_spec.rb @@ -78,7 +78,7 @@ RSpec.describe GitlabSchema.types['UserMergeRequestInteraction'] do merge_request.reviewers << user end - it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['UNREVIEWED'].value) } + it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['ATTENTION_REQUESTED'].value) } it 'implies not reviewed' do expect(resolve(:reviewed)).to be false @@ -87,7 +87,8 @@ RSpec.describe GitlabSchema.types['UserMergeRequestInteraction'] do context 'when the user has provided a review' do before do - merge_request.merge_request_reviewers.create!(reviewer: user, state: MergeRequestReviewer.states['reviewed']) + reviewer = merge_request.merge_request_reviewers.create!(reviewer: user) + reviewer.update!(state: MergeRequestReviewer.states['reviewed']) end it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['REVIEWED'].value) } diff --git a/spec/helpers/admin/deploy_key_helper_spec.rb b/spec/helpers/admin/deploy_key_helper_spec.rb new file mode 100644 index 00000000000..ca951ccf485 --- /dev/null +++ b/spec/helpers/admin/deploy_key_helper_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Admin::DeployKeyHelper do + describe '#admin_deploy_keys_data' do + let_it_be(:edit_path) { '/admin/deploy_keys/:id/edit' } + let_it_be(:delete_path) { '/admin/deploy_keys/:id' } + let_it_be(:create_path) { '/admin/deploy_keys/new' } + let_it_be(:empty_state_svg_path) { '/assets/illustrations/empty-state/empty-deploy-keys-lg.svg' } + + subject(:result) { helper.admin_deploy_keys_data } + + it 'returns correct hash' do + expect(helper).to receive(:edit_admin_deploy_key_path).with(':id').and_return(edit_path) + expect(helper).to receive(:admin_deploy_key_path).with(':id').and_return(delete_path) + expect(helper).to receive(:new_admin_deploy_key_path).and_return(create_path) + expect(helper).to receive(:image_path).with('illustrations/empty-state/empty-deploy-keys-lg.svg').and_return(empty_state_svg_path) + + expect(result).to eq({ + edit_path: edit_path, + delete_path: delete_path, + create_path: create_path, + empty_state_svg_path: empty_state_svg_path + }) + end + end +end diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb index cb4b6915b20..ec949fde30e 100644 --- a/spec/helpers/boards_helper_spec.rb +++ b/spec/helpers/boards_helper_spec.rb @@ -23,14 +23,14 @@ RSpec.describe BoardsHelper do it 'returns correct path for base group' do assign(:board, group_board) - expect(helper.build_issue_link_base).to eq('/base/:project_path/issues') + expect(helper.build_issue_link_base).to eq('/:project_path/-/issues') end it 'returns correct path for subgroup' do subgroup = create(:group, parent: base_group, path: 'sub') assign(:board, create(:board, group: subgroup)) - expect(helper.build_issue_link_base).to eq('/base/sub/:project_path/issues') + expect(helper.build_issue_link_base).to eq('/:project_path/-/issues') end end end @@ -149,7 +149,7 @@ RSpec.describe BoardsHelper do end it 'returns correct path for base group' do - expect(helper.build_issue_link_base).to eq("/#{base_group.full_path}/:project_path/issues") + expect(helper.build_issue_link_base).to eq("/:project_path/-/issues") end it 'returns required label endpoints' do diff --git a/spec/helpers/ci/pipelines_helper_spec.rb b/spec/helpers/ci/pipelines_helper_spec.rb index 94b5e707d73..751bcc97582 100644 --- a/spec/helpers/ci/pipelines_helper_spec.rb +++ b/spec/helpers/ci/pipelines_helper_spec.rb @@ -71,4 +71,26 @@ RSpec.describe Ci::PipelinesHelper do it { expect(has_gitlab_ci?).to eq(result) } end end + + describe 'has_pipeline_badges?' do + let(:pipeline) { create(:ci_empty_pipeline) } + + subject { helper.has_pipeline_badges?(pipeline) } + + context 'when pipeline has a badge' do + before do + pipeline.drop!(:config_error) + end + + it 'shows pipeline badges' do + expect(subject).to eq(true) + end + end + + context 'when pipeline has no badges' do + it 'shows pipeline badges' do + expect(subject).to eq(false) + end + end + end end diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index 49ea2ac8d3b..173a0d3ab3c 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Ci::RunnersHelper do - let_it_be(:user, refind: true) { create(:user) } + let_it_be(:user) { create(:user) } before do allow(helper).to receive(:current_user).and_return(user) @@ -12,22 +12,22 @@ RSpec.describe Ci::RunnersHelper do describe '#runner_status_icon', :clean_gitlab_redis_cache do it "returns - not contacted yet" do runner = create(:ci_runner) - expect(runner_status_icon(runner)).to include("not connected yet") + expect(helper.runner_status_icon(runner)).to include("not connected yet") end it "returns offline text" do runner = create(:ci_runner, contacted_at: 1.day.ago, active: true) - expect(runner_status_icon(runner)).to include("Runner is offline") + expect(helper.runner_status_icon(runner)).to include("Runner is offline") end it "returns online text" do runner = create(:ci_runner, contacted_at: 1.second.ago, active: true) - expect(runner_status_icon(runner)).to include("Runner is online") + expect(helper.runner_status_icon(runner)).to include("Runner is online") end it "returns paused text" do runner = create(:ci_runner, contacted_at: 1.second.ago, active: false) - expect(runner_status_icon(runner)).to include("Runner is paused") + expect(helper.runner_status_icon(runner)).to include("Runner is paused") end end @@ -42,7 +42,7 @@ RSpec.describe Ci::RunnersHelper do context 'without sorting' do it 'returns cached value' do - expect(runner_contacted_at(runner)).to eq(contacted_at_cached) + expect(helper.runner_contacted_at(runner)).to eq(contacted_at_cached) end end @@ -52,7 +52,7 @@ RSpec.describe Ci::RunnersHelper do end it 'returns cached value' do - expect(runner_contacted_at(runner)).to eq(contacted_at_cached) + expect(helper.runner_contacted_at(runner)).to eq(contacted_at_cached) end end @@ -62,29 +62,63 @@ RSpec.describe Ci::RunnersHelper do end it 'returns stored value' do - expect(runner_contacted_at(runner)).to eq(contacted_at_stored) + expect(helper.runner_contacted_at(runner)).to eq(contacted_at_stored) end end end + describe '#admin_runners_data_attributes' do + let_it_be(:admin) { create(:user, :admin) } + let_it_be(:instance_runner) { create(:ci_runner, :instance) } + let_it_be(:project_runner) { create(:ci_runner, :project ) } + + before do + allow(helper).to receive(:current_user).and_return(admin) + end + + 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, + active_runners_count: '0', + all_runners_count: '2', + instance_runners_count: '1', + group_runners_count: '0', + project_runners_count: '1' + }) + end + end + describe '#group_shared_runners_settings_data' do - let(:group) { create(:group, parent: parent, shared_runners_enabled: false) } - let(:parent) { create(:group) } + let_it_be(:parent) { create(:group) } + let_it_be(:group) { create(:group, parent: parent, shared_runners_enabled: false) } + + let(:runner_constants) do + { + runner_enabled: Namespace::SR_ENABLED, + runner_disabled: Namespace::SR_DISABLED_AND_UNOVERRIDABLE, + runner_allow_override: Namespace::SR_DISABLED_WITH_OVERRIDE + } + end it 'returns group data for top level group' do - data = group_shared_runners_settings_data(parent) + result = { + update_path: "/api/v4/groups/#{parent.id}", + shared_runners_availability: Namespace::SR_ENABLED, + parent_shared_runners_availability: nil + }.merge(runner_constants) - expect(data[:update_path]).to eq("/api/v4/groups/#{parent.id}") - expect(data[:shared_runners_availability]).to eq('enabled') - expect(data[:parent_shared_runners_availability]).to eq(nil) + expect(helper.group_shared_runners_settings_data(parent)).to eq result end it 'returns group data for child group' do - data = group_shared_runners_settings_data(group) + result = { + update_path: "/api/v4/groups/#{group.id}", + shared_runners_availability: Namespace::SR_DISABLED_AND_UNOVERRIDABLE, + parent_shared_runners_availability: Namespace::SR_ENABLED + }.merge(runner_constants) - expect(data[:update_path]).to eq("/api/v4/groups/#{group.id}") - expect(data[:shared_runners_availability]).to eq(Namespace::SR_DISABLED_AND_UNOVERRIDABLE) - expect(data[:parent_shared_runners_availability]).to eq('enabled') + expect(helper.group_shared_runners_settings_data(group)).to eq result end end @@ -92,7 +126,7 @@ RSpec.describe Ci::RunnersHelper do let(:group) { create(:group) } it 'returns group data to render a runner list' do - data = group_runners_data_attributes(group) + data = helper.group_runners_data_attributes(group) expect(data[:registration_token]).to eq(group.runners_token) expect(data[:group_id]).to eq(group.id) diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb index f1e19f17c72..51f111917d1 100644 --- a/spec/helpers/clusters_helper_spec.rb +++ b/spec/helpers/clusters_helper_spec.rb @@ -59,54 +59,96 @@ RSpec.describe ClustersHelper do end end - describe '#js_cluster_agents_list_data' do - let_it_be(:project) { build(:project, :repository) } + describe '#js_clusters_list_data' do + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { build(:project) } + let_it_be(:clusterable) { ClusterablePresenter.fabricate(project, current_user: current_user) } - subject { helper.js_cluster_agents_list_data(project) } + subject { helper.js_clusters_list_data(clusterable) } - it 'displays project default branch' do - expect(subject[:default_branch_name]).to eq(project.default_branch) + it 'displays endpoint path' do + expect(subject[:endpoint]).to eq("#{project_path(project)}/-/clusters.json") end - it 'displays image path' do - expect(subject[:empty_state_image]).to match(%r(/illustrations/logos/clusters_empty|svg)) + it 'generates svg image data', :aggregate_failures do + expect(subject.dig(:img_tags, :aws, :path)).to match(%r(/illustrations/logos/amazon_eks|svg)) + expect(subject.dig(:img_tags, :default, :path)).to match(%r(/illustrations/logos/kubernetes|svg)) + expect(subject.dig(:img_tags, :gcp, :path)).to match(%r(/illustrations/logos/google_gke|svg)) + + expect(subject.dig(:img_tags, :aws, :text)).to eq('Amazon EKS') + expect(subject.dig(:img_tags, :default, :text)).to eq('Kubernetes Cluster') + expect(subject.dig(:img_tags, :gcp, :text)).to eq('Google GKE') end - it 'displays project path' do - expect(subject[:project_path]).to eq(project.full_path) + it 'displays and ancestor_help_path' do + expect(subject[:ancestor_help_path]).to eq(help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')) end - it 'generates docs urls' do - expect(subject[:agent_docs_url]).to eq(help_page_path('user/clusters/agent/index')) - expect(subject[:install_docs_url]).to eq(help_page_path('administration/clusters/kas')) - expect(subject[:get_started_docs_url]).to eq(help_page_path('user/clusters/agent/index', anchor: 'define-a-configuration-repository')) - expect(subject[:integration_docs_url]).to eq(help_page_path('user/clusters/agent/index', anchor: 'get-started-with-gitops-and-the-gitlab-agent')) + it 'displays empty image path' do + expect(subject[:clusters_empty_state_image]).to match(%r(/illustrations/empty-state/empty-state-clusters|svg)) end - it 'displays kas address' do - expect(subject[:kas_address]).to eq(Gitlab::Kas.external_url) + it 'displays create cluster using certificate path' do + expect(subject[:new_cluster_path]).to eq("#{project_path(project)}/-/clusters/new?tab=create") + end + + context 'user has no permissions to create a cluster' do + it 'displays that user can\t add cluster' do + expect(subject[:can_add_cluster]).to eq("false") + end + end + + context 'user is a maintainer' do + before do + project.add_maintainer(current_user) + end + + it 'displays that the user can add cluster' do + expect(subject[:can_add_cluster]).to eq("true") + end + end + + context 'project cluster' do + it 'doesn\'t display empty state help text' do + expect(subject[:empty_state_help_text]).to be_nil + end + end + + context 'group cluster' do + let_it_be(:group) { create(:group) } + let_it_be(:clusterable) { ClusterablePresenter.fabricate(group, current_user: current_user) } + + it 'displays empty state help text' do + expect(subject[:empty_state_help_text]).to eq(s_('ClusterIntegration|Adding an integration to your group will share the cluster across all your projects.')) + end end end - describe '#js_clusters_list_data' do - subject { helper.js_clusters_list_data('/path') } + describe '#js_clusters_data' do + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { build(:project) } + let_it_be(:clusterable) { ClusterablePresenter.fabricate(project, current_user: current_user) } - it 'displays endpoint path' do - expect(subject[:endpoint]).to eq('/path') + subject { helper.js_clusters_data(clusterable) } + + it 'displays project default branch' do + expect(subject[:default_branch_name]).to eq(project.default_branch) end - it 'generates svg image data', :aggregate_failures do - expect(subject.dig(:img_tags, :aws, :path)).to match(%r(/illustrations/logos/amazon_eks|svg)) - expect(subject.dig(:img_tags, :default, :path)).to match(%r(/illustrations/logos/kubernetes|svg)) - expect(subject.dig(:img_tags, :gcp, :path)).to match(%r(/illustrations/logos/google_gke|svg)) + it 'displays image path' do + expect(subject[:empty_state_image]).to match(%r(/illustrations/empty-state/empty-state-agents|svg)) + end - expect(subject.dig(:img_tags, :aws, :text)).to eq('Amazon EKS') - expect(subject.dig(:img_tags, :default, :text)).to eq('Kubernetes Cluster') - expect(subject.dig(:img_tags, :gcp, :text)).to eq('Google GKE') + it 'displays project path' do + expect(subject[:project_path]).to eq(project.full_path) end - it 'displays and ancestor_help_path' do - expect(subject[:ancestor_help_path]).to eq(help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')) + it 'displays add cluster using certificate path' do + expect(subject[:add_cluster_path]).to eq("#{project_path(project)}/-/clusters/new?tab=add") + end + + it 'displays kas address' do + expect(subject[:kas_address]).to eq(Gitlab::Kas.external_url) end end @@ -152,4 +194,24 @@ RSpec.describe ClustersHelper do end end end + + describe '#display_cluster_agents?' do + subject { helper.display_cluster_agents?(clusterable) } + + context 'when clusterable is a project' do + let(:clusterable) { build(:project) } + + it 'allows agents to display' do + expect(subject).to be_truthy + end + end + + context 'when clusterable is a group' do + let(:clusterable) { build(:group) } + + it 'does not allow agents to display' do + expect(subject).to be_falsey + end + end + end end diff --git a/spec/helpers/emoji_helper_spec.rb b/spec/helpers/emoji_helper_spec.rb index 15e4ce03960..6f4c962c0fb 100644 --- a/spec/helpers/emoji_helper_spec.rb +++ b/spec/helpers/emoji_helper_spec.rb @@ -6,6 +6,7 @@ RSpec.describe EmojiHelper do describe '#emoji_icon' do let(:options) { {} } let(:emoji_text) { 'rocket' } + let(:unicode_version) { '6.0' } let(:aria_hidden_option) { "aria-hidden=\"true\"" } subject { helper.emoji_icon(emoji_text, options) } @@ -14,7 +15,7 @@ RSpec.describe EmojiHelper do is_expected.to include(' validate_query_project_prometheus_metrics_path(project), 'custom_metrics_available' => 'true', 'alerts_endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json), - 'prometheus_alerts_available' => 'true', 'custom_dashboard_base_path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT, 'operations_settings_path' => project_settings_operations_path(project), 'can_access_operations_settings' => 'true', - 'panel_preview_endpoint' => project_metrics_dashboards_builder_path(project, format: :json), - 'has_managed_prometheus' => 'false' + 'panel_preview_endpoint' => project_metrics_dashboards_builder_path(project, format: :json) ) end @@ -63,20 +61,6 @@ RSpec.describe EnvironmentsHelper do end end - context 'without read_prometheus_alerts permission' do - before do - allow(helper).to receive(:can?) - .with(user, :read_prometheus_alerts, project) - .and_return(false) - end - - it 'returns false' do - expect(metrics_data).to include( - 'prometheus_alerts_available' => 'false' - ) - end - end - context 'with metrics_setting' do before do create(:project_metrics_setting, project: project, external_dashboard_url: 'http://gitlab.com') @@ -120,52 +104,6 @@ RSpec.describe EnvironmentsHelper do end end end - - context 'has_managed_prometheus' do - context 'without prometheus integration' do - it "doesn't have managed prometheus" do - expect(metrics_data).to include( - 'has_managed_prometheus' => 'false' - ) - end - end - - context 'with prometheus integration' do - let_it_be(:prometheus_integration) { create(:prometheus_integration, project: project) } - - context 'when manual prometheus integration is active' do - it "doesn't have managed prometheus" do - prometheus_integration.update!(manual_configuration: true) - - expect(metrics_data).to include( - 'has_managed_prometheus' => 'false' - ) - end - end - - context 'when prometheus integration is inactive' do - it "doesn't have managed prometheus" do - prometheus_integration.update!(manual_configuration: false) - - expect(metrics_data).to include( - 'has_managed_prometheus' => 'false' - ) - end - end - - context 'when a cluster prometheus is available' do - let(:cluster) { create(:cluster, projects: [project]) } - - it 'has managed prometheus' do - create(:clusters_integrations_prometheus, cluster: cluster) - - expect(metrics_data).to include( - 'has_managed_prometheus' => 'true' - ) - end - end - end - end end describe '#custom_metrics_available?' do diff --git a/spec/helpers/graph_helper_spec.rb b/spec/helpers/graph_helper_spec.rb index 0930417accb..a5d4e1313e1 100644 --- a/spec/helpers/graph_helper_spec.rb +++ b/spec/helpers/graph_helper_spec.rb @@ -27,4 +27,16 @@ RSpec.describe GraphHelper do expect(should_render_dora_charts).to be(false) end end + + describe '#should_render_quality_summary' do + let(:project) { create(:project, :private) } + + before do + self.instance_variable_set(:@project, project) + end + + it 'always returns false' do + expect(should_render_quality_summary).to be(false) + end + end end diff --git a/spec/helpers/groups/settings_helper_spec.rb b/spec/helpers/groups/settings_helper_spec.rb new file mode 100644 index 00000000000..f8c0bfc19a1 --- /dev/null +++ b/spec/helpers/groups/settings_helper_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::SettingsHelper do + include GroupsHelper + + let_it_be(:group) { create(:group, path: "foo") } + + describe('#group_settings_confirm_modal_data') do + using RSpec::Parameterized::TableSyntax + + fake_form_id = "fake_form_id" + + where(:is_paid, :is_button_disabled, :form_value_id) do + true | "true" | nil + true | "true" | fake_form_id + false | "false" | nil + false | "false" | fake_form_id + end + + with_them do + it "returns expected parameters" do + allow(group).to receive(:paid?).and_return(is_paid) + + expected = helper.group_settings_confirm_modal_data(group, form_value_id) + expect(expected).to eq({ + button_text: "Remove group", + confirm_danger_message: remove_group_message(group), + remove_form_id: form_value_id, + phrase: group.full_path, + button_testid: "remove-group-button", + disabled: is_button_disabled + }) + end + end + end +end diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 4d647696130..8859ed27022 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -92,7 +92,7 @@ RSpec.describe GroupsHelper do shared_examples 'correct ancestor order' do it 'outputs the groups in the correct order' do expect(subject) - .to match(%r{
  • #{deep_nested_group.name}.*
  • .*#{very_deep_nested_group.name}}m) + .to match(%r{
  • #{deep_nested_group.name}.*
  • .*#{very_deep_nested_group.name}}m) end end diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb index e0e05140d6c..02f0416a17a 100644 --- a/spec/helpers/invite_members_helper_spec.rb +++ b/spec/helpers/invite_members_helper_spec.rb @@ -59,7 +59,84 @@ RSpec.describe InviteMembersHelper do no_selection_areas_of_focus: [] } - expect(helper.common_invite_modal_dataset(project)).to match(attributes) + expect(helper.common_invite_modal_dataset(project)).to include(attributes) + end + end + + context 'tasks_to_be_done' do + subject(:output) { helper.common_invite_modal_dataset(source) } + + let_it_be(:source) { project } + + before do + stub_experiments(invite_members_for_task: true) + end + + context 'when not logged in' do + before do + allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' }) + end + + it "doesn't have the tasks to be done attributes" do + expect(output[:tasks_to_be_done_options]).to be_nil + expect(output[:projects]).to be_nil + expect(output[:new_project_path]).to be_nil + end + end + + context 'when logged in but the open_modal param is not present' do + before do + allow(helper).to receive(:current_user).and_return(developer) + end + + it "doesn't have the tasks to be done attributes" do + expect(output[:tasks_to_be_done_options]).to be_nil + expect(output[:projects]).to be_nil + expect(output[:new_project_path]).to be_nil + end + end + + context 'when logged in and the open_modal param is present' do + before do + allow(helper).to receive(:current_user).and_return(developer) + allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' }) + end + + context 'for a group' do + let_it_be(:source) { create(:group, projects: [project]) } + + it 'has the expected attributes', :aggregate_failures do + expect(output[:tasks_to_be_done_options]).to eq( + [ + { value: :code, text: 'Create/import code into a project (repository)' }, + { value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' }, + { value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' } + ].to_json + ) + expect(output[:projects]).to eq( + [{ id: project.id, title: project.title }].to_json + ) + expect(output[:new_project_path]).to eq( + new_project_path(namespace_id: source.id) + ) + end + end + + context 'for a project' do + it 'has the expected attributes', :aggregate_failures do + expect(output[:tasks_to_be_done_options]).to eq( + [ + { value: :code, text: 'Create/import code into a project (repository)' }, + { value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' }, + { value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' } + ].to_json + ) + expect(output[:projects]).to eq( + [{ id: project.id, title: project.title }].to_json + ) + expect(output[:new_project_path]).to eq('') + end + end end end end diff --git a/spec/helpers/issuables_description_templates_helper_spec.rb b/spec/helpers/issuables_description_templates_helper_spec.rb index 55649e9087a..6b05bab7432 100644 --- a/spec/helpers/issuables_description_templates_helper_spec.rb +++ b/spec/helpers/issuables_description_templates_helper_spec.rb @@ -44,7 +44,7 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do end end - describe '#issuable_templates_names' do + describe '#selected_template' do let_it_be(:project) { build(:project) } before do @@ -63,7 +63,14 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do end it 'returns project templates' do - expect(helper.issuable_templates_names(Issue.new)).to eq(%w[another_issue_template custom_issue_template]) + value = [ + "", + [ + { name: "another_issue_template", id: "another_issue_template", project_id: project.id }, + { name: "custom_issue_template", id: "custom_issue_template", project_id: project.id } + ] + ].to_json + expect(helper.available_service_desk_templates_for(@project)).to eq(value) end end @@ -71,7 +78,8 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do let(:templates) { {} } it 'returns empty array' do - expect(helper.issuable_templates_names(Issue.new)).to eq([]) + value = [].to_json + expect(helper.available_service_desk_templates_for(@project)).to eq(value) end end end diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 30049745433..fa19395ebc7 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -169,26 +169,9 @@ RSpec.describe IssuablesHelper do stub_const("Gitlab::IssuablesCountForState::THRESHOLD", 1000) end - context 'when feature flag cached_issues_state_count is disabled' do - before do - stub_feature_flags(cached_issues_state_count: false) - end - - it 'returns complete count' do - expect(helper.issuables_state_counter_text(:issues, :opened, true)) - .to eq('Open 1,100') - end - end - - context 'when feature flag cached_issues_state_count is enabled' do - before do - stub_feature_flags(cached_issues_state_count: true) - end - - it 'returns truncated count' do - expect(helper.issuables_state_counter_text(:issues, :opened, true)) - .to eq('Open 1.1k') - end + it 'returns truncated count' do + expect(helper.issuables_state_counter_text(:issues, :opened, true)) + .to eq('Open 1.1k') end end end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 850051c7875..43b27dded3b 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -326,6 +326,7 @@ RSpec.describe IssuesHelper do new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.id }), project_import_jira_path: project_import_jira_path(project), quick_actions_help_path: help_page_path('user/project/quick_actions'), + releases_path: project_releases_path(project, format: :json), reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'), rss_path: '#', show_new_issue_link: 'true', diff --git a/spec/helpers/learn_gitlab_helper_spec.rb b/spec/helpers/learn_gitlab_helper_spec.rb index 1159fd96d59..b9f34853a77 100644 --- a/spec/helpers/learn_gitlab_helper_spec.rb +++ b/spec/helpers/learn_gitlab_helper_spec.rb @@ -11,9 +11,6 @@ RSpec.describe LearnGitlabHelper do let_it_be(:namespace) { project.namespace } before do - project.add_developer(user) - - allow(helper).to receive(:user).and_return(user) allow_next_instance_of(LearnGitlab::Project) do |learn_gitlab| allow(learn_gitlab).to receive(:project).and_return(project) end @@ -22,38 +19,7 @@ RSpec.describe LearnGitlabHelper do OnboardingProgress.register(namespace, :git_write) end - describe '.onboarding_actions_data' do - subject(:onboarding_actions_data) { helper.onboarding_actions_data(project) } - - it 'has all actions' do - expect(onboarding_actions_data.keys).to contain_exactly( - :issue_created, - :git_write, - :pipeline_created, - :merge_request_created, - :user_added, - :trial_started, - :required_mr_approvals_enabled, - :code_owners_enabled, - :security_scan_enabled - ) - end - - it 'sets correct path and completion status' do - expect(onboarding_actions_data[:git_write]).to eq({ - url: project_issue_url(project, LearnGitlab::Onboarding::ACTION_ISSUE_IDS[:git_write]), - completed: true, - svg: helper.image_path("learn_gitlab/git_write.svg") - }) - expect(onboarding_actions_data[:pipeline_created]).to eq({ - url: project_issue_url(project, LearnGitlab::Onboarding::ACTION_ISSUE_IDS[:pipeline_created]), - completed: false, - svg: helper.image_path("learn_gitlab/pipeline_created.svg") - }) - end - end - - describe '.learn_gitlab_enabled?' do + describe '#learn_gitlab_enabled?' do using RSpec::Parameterized::TableSyntax let_it_be(:user) { create(:user) } @@ -89,14 +55,121 @@ RSpec.describe LearnGitlabHelper do end end - describe '.onboarding_sections_data' do - subject(:sections) { helper.onboarding_sections_data } + describe '#learn_gitlab_data' do + subject(:learn_gitlab_data) { helper.learn_gitlab_data(project) } + + let(:onboarding_actions_data) { Gitlab::Json.parse(learn_gitlab_data[:actions]).deep_symbolize_keys } + let(:onboarding_sections_data) { Gitlab::Json.parse(learn_gitlab_data[:sections]).deep_symbolize_keys } + + shared_examples 'has all data' do + it 'has all actions' do + expected_keys = [ + :issue_created, + :git_write, + :pipeline_created, + :merge_request_created, + :user_added, + :trial_started, + :required_mr_approvals_enabled, + :code_owners_enabled, + :security_scan_enabled + ] + + expect(onboarding_actions_data.keys).to contain_exactly(*expected_keys) + end - it 'has the right keys' do - expect(sections.keys).to contain_exactly(:deploy, :plan, :workspace) + it 'has all section data', :aggregate_failures do + expect(onboarding_sections_data.keys).to contain_exactly(:deploy, :plan, :workspace) + expect(onboarding_sections_data.values.map { |section| section.keys }).to match_array([[:svg]] * 3) + end end - it 'has the svg' do - expect(sections.values.map { |section| section.keys }).to eq([[:svg]] * 3) + + it_behaves_like 'has all data' + + it 'sets correct paths' do + expect(onboarding_actions_data).to match({ + trial_started: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/2\z}) + ), + issue_created: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/4\z}) + ), + git_write: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/6\z}) + ), + pipeline_created: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/7\z}) + ), + user_added: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/8\z}) + ), + merge_request_created: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/9\z}) + ), + code_owners_enabled: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/10\z}) + ), + required_mr_approvals_enabled: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/11\z}) + ), + security_scan_enabled: a_hash_including( + url: a_string_matching(%r{docs\.gitlab\.com/ee/user/application_security/security_dashboard/#gitlab-security-dashboard-security-center-and-vulnerability-reports\z}) + ) + }) + end + + it 'sets correct completion statuses' do + expect(onboarding_actions_data).to match({ + issue_created: a_hash_including(completed: false), + git_write: a_hash_including(completed: true), + pipeline_created: a_hash_including(completed: false), + merge_request_created: a_hash_including(completed: false), + user_added: a_hash_including(completed: false), + trial_started: a_hash_including(completed: false), + required_mr_approvals_enabled: a_hash_including(completed: false), + code_owners_enabled: a_hash_including(completed: false), + security_scan_enabled: a_hash_including(completed: false) + }) + end + + context 'when in the new action URLs experiment' do + before do + stub_experiments(change_continuous_onboarding_link_urls: :candidate) + end + + it_behaves_like 'has all data' + + it 'sets mostly new paths' do + expect(onboarding_actions_data).to match({ + trial_started: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/2\z}) + ), + issue_created: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues\z}) + ), + git_write: a_hash_including( + url: a_string_matching(%r{/learn_gitlab\z}) + ), + pipeline_created: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/pipelines\z}) + ), + user_added: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/project_members\z}) + ), + merge_request_created: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/merge_requests\z}) + ), + code_owners_enabled: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/10\z}) + ), + required_mr_approvals_enabled: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/issues/11\z}) + ), + security_scan_enabled: a_hash_including( + url: a_string_matching(%r{/learn_gitlab/-/security/configuration\z}) + ) + }) + end end end end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index c671379c4b4..e94eb63fc2c 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -68,4 +68,10 @@ RSpec.describe MembersHelper do it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.full_name}\" project?" } it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave the \"#{group.name}\" group?" } end + + describe '#localized_tasks_to_be_done_choices' do + it 'has a translation for all `TASKS_TO_BE_DONE` keys' do + expect(localized_tasks_to_be_done_choices).to include(*MemberTask::TASKS.keys) + end + end end diff --git a/spec/helpers/nav/top_nav_helper_spec.rb b/spec/helpers/nav/top_nav_helper_spec.rb index da7e5d5dce2..10bd45e3189 100644 --- a/spec/helpers/nav/top_nav_helper_spec.rb +++ b/spec/helpers/nav/top_nav_helper_spec.rb @@ -188,6 +188,11 @@ RSpec.describe Nav::TopNavHelper do href: '/explore', id: 'explore', title: 'Explore projects' + ), + ::Gitlab::Nav::TopNavMenuItem.build( + href: '/explore/projects/topics', + id: 'topics', + title: 'Explore topics' ) ] expect(projects_view[:linksPrimary]).to eq(expected_links_primary) diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index fc62bbf8bf8..913a38d353f 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -322,11 +322,21 @@ RSpec.describe NotesHelper do describe '#notes_data' do let(:issue) { create(:issue, project: project) } - it 'sets last_fetched_at to 0 when start_at_zero is true' do + before do @project = project @noteable = issue + allow(helper).to receive(:current_user).and_return(guest) + end + + it 'sets last_fetched_at to 0 when start_at_zero is true' do expect(helper.notes_data(issue, true)[:lastFetchedAt]).to eq(0) end + + it 'includes the current notes filter for the user' do + guest.set_notes_filter(UserPreference::NOTES_FILTERS[:only_comments], issue) + + expect(helper.notes_data(issue)[:notesFilter]).to eq(UserPreference::NOTES_FILTERS[:only_comments]) + end end end diff --git a/spec/helpers/one_trust_helper_spec.rb b/spec/helpers/one_trust_helper_spec.rb index 85c38885304..20b731ac73d 100644 --- a/spec/helpers/one_trust_helper_spec.rb +++ b/spec/helpers/one_trust_helper_spec.rb @@ -4,11 +4,8 @@ require "spec_helper" RSpec.describe OneTrustHelper do describe '#one_trust_enabled?' do - let(:user) { nil } - before do stub_config(extra: { one_trust_id: SecureRandom.uuid }) - allow(helper).to receive(:current_user).and_return(user) end subject(:one_trust_enabled?) { helper.one_trust_enabled? } @@ -18,20 +15,10 @@ RSpec.describe OneTrustHelper do stub_feature_flags(ecomm_instrumentation: false) end - context 'when id is set and no user is set' do - let(:user) { instance_double('User') } - - it { is_expected.to be_falsey } - end + it { is_expected.to be_falsey } end context 'with ecomm_instrumentation feature flag enabled' do - context 'when current user is set' do - let(:user) { instance_double('User') } - - it { is_expected.to be_falsey } - end - context 'when no id is set' do before do stub_config(extra: {}) @@ -39,10 +26,6 @@ RSpec.describe OneTrustHelper do it { is_expected.to be_falsey } end - - context 'when id is set and no user is set' do - it { is_expected.to be_truthy } - end end end end diff --git a/spec/helpers/projects/alert_management_helper_spec.rb b/spec/helpers/projects/alert_management_helper_spec.rb index 2450f7838b3..0a5c4bedaa6 100644 --- a/spec/helpers/projects/alert_management_helper_spec.rb +++ b/spec/helpers/projects/alert_management_helper_spec.rb @@ -34,7 +34,6 @@ RSpec.describe Projects::AlertManagementHelper do 'empty-alert-svg-path' => match_asset_path('/assets/illustrations/alert-management-empty-state.svg'), 'user-can-enable-alert-management' => 'true', 'alert-management-enabled' => 'false', - 'has-managed-prometheus' => 'false', 'text-query': nil, 'assignee-username-query': nil ) @@ -45,52 +44,26 @@ RSpec.describe Projects::AlertManagementHelper do let_it_be(:prometheus_integration) { create(:prometheus_integration, project: project) } context 'when manual prometheus integration is active' do - it "enables alert management and doesn't show managed prometheus" do + it "enables alert management" do prometheus_integration.update!(manual_configuration: true) expect(data).to include( 'alert-management-enabled' => 'true' ) - expect(data).to include( - 'has-managed-prometheus' => 'false' - ) - end - end - - context 'when a cluster prometheus is available' do - let(:cluster) { create(:cluster, projects: [project]) } - - it 'has managed prometheus' do - create(:clusters_integrations_prometheus, cluster: cluster) - - expect(data).to include( - 'has-managed-prometheus' => 'true' - ) end end - context 'when prometheus integration is inactive' do - it 'disables alert management and hides managed prometheus' do + context 'when prometheus service is inactive' do + it 'disables alert management' do prometheus_integration.update!(manual_configuration: false) expect(data).to include( 'alert-management-enabled' => 'false' ) - expect(data).to include( - 'has-managed-prometheus' => 'false' - ) end end end - context 'without prometheus integration' do - it "doesn't have managed prometheus" do - expect(data).to include( - 'has-managed-prometheus' => 'false' - ) - end - end - context 'with http integration' do let_it_be(:integration) { create(:alert_management_http_integration, project: project) } diff --git a/spec/helpers/projects/incidents_helper_spec.rb b/spec/helpers/projects/incidents_helper_spec.rb index 7a8a6d5222f..d0dc18d56b0 100644 --- a/spec/helpers/projects/incidents_helper_spec.rb +++ b/spec/helpers/projects/incidents_helper_spec.rb @@ -5,7 +5,8 @@ require 'spec_helper' RSpec.describe Projects::IncidentsHelper do include Gitlab::Routing.url_helpers - let(:project) { create(:project) } + let(:user) { build_stubbed(:user) } + let(:project) { build_stubbed(:project) } let(:project_path) { project.full_path } let(:new_issue_path) { new_project_issue_path(project) } let(:issue_path) { project_issues_path(project) } @@ -17,21 +18,43 @@ RSpec.describe Projects::IncidentsHelper do } end + before do + allow(helper).to receive(:current_user).and_return(user) + allow(helper).to receive(:can?) + .with(user, :create_incident, project) + .and_return(can_create_incident) + end + describe '#incidents_data' do subject(:data) { helper.incidents_data(project, params) } - it 'returns frontend configuration' do - expect(data).to include( - 'project-path' => project_path, - 'new-issue-path' => new_issue_path, - 'incident-template-name' => 'incident', - 'incident-type' => 'incident', - 'issue-path' => issue_path, - 'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'), - 'text-query': 'search text', - 'author-username-query': 'root', - 'assignee-username-query': 'max.power' - ) + shared_examples 'frontend configuration' do + it 'returns frontend configuration' do + expect(data).to include( + 'project-path' => project_path, + 'new-issue-path' => new_issue_path, + 'incident-template-name' => 'incident', + 'incident-type' => 'incident', + 'issue-path' => issue_path, + 'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'), + 'text-query': 'search text', + 'author-username-query': 'root', + 'assignee-username-query': 'max.power', + 'can-create-incident': can_create_incident.to_s + ) + end + end + + context 'when user can create incidents' do + let(:can_create_incident) { true } + + include_examples 'frontend configuration' + end + + context 'when user cannot create incidents' do + let(:can_create_incident) { false } + + include_examples 'frontend configuration' end end end diff --git a/spec/helpers/projects/security/configuration_helper_spec.rb b/spec/helpers/projects/security/configuration_helper_spec.rb index c5049bd87f0..4c30ba87897 100644 --- a/spec/helpers/projects/security/configuration_helper_spec.rb +++ b/spec/helpers/projects/security/configuration_helper_spec.rb @@ -8,6 +8,6 @@ RSpec.describe Projects::Security::ConfigurationHelper do describe 'security_upgrade_path' do subject { security_upgrade_path } - it { is_expected.to eq('https://about.gitlab.com/pricing/') } + it { is_expected.to eq("https://#{ApplicationHelper.promo_host}/pricing/") } end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 5d52c9178cb..5d2af567549 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -268,7 +268,7 @@ RSpec.describe ProjectsHelper do end end - describe '#link_to_set_password' do + describe '#no_password_message' do let(:user) { create(:user, password_automatically_set: true) } before do @@ -276,18 +276,18 @@ RSpec.describe ProjectsHelper do end context 'password authentication is enabled for Git' do - it 'returns link to set a password' do + it 'returns message prompting user to set password or set up a PAT' do stub_application_setting(password_authentication_enabled_for_git?: true) - expect(helper.link_to_set_password).to match %r{set a password} + expect(helper.no_password_message).to eq('Your account is authenticated with SSO or SAML. To push and pull over HTTP with Git using this account, you must set a password or set up a Personal Access Token to use instead of a password. For more information, see Clone with HTTPS.') end end context 'password authentication is disabled for Git' do - it 'returns link to create a personal access token' do + it 'returns message prompting user to set up a PAT' do stub_application_setting(password_authentication_enabled_for_git?: false) - expect(helper.link_to_set_password).to match %r{create a personal access token} + expect(helper.no_password_message).to eq('Your account is authenticated with SSO or SAML. To push and pull over HTTP with Git using this account, you must set up a Personal Access Token to use instead of a password. For more information, see Clone with HTTPS.') end end end @@ -983,4 +983,12 @@ RSpec.describe ProjectsHelper do it { is_expected.not_to include('project-highlight-puc') } end end + + describe "#delete_confirm_phrase" do + subject { helper.delete_confirm_phrase(project) } + + it 'includes the project path with namespace' do + expect(subject).to eq(project.path_with_namespace) + end + end end diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb index a28a86d1f53..82ed893289d 100644 --- a/spec/helpers/routing/pseudonymization_helper_spec.rb +++ b/spec/helpers/routing/pseudonymization_helper_spec.rb @@ -25,95 +25,196 @@ RSpec.describe ::Routing::PseudonymizationHelper do describe 'when url has params to mask' do context 'with controller for MR' do - let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/merge_requests/#{merge_request.id}" } + let(:masked_url) { "http://localhost/namespace#{group.id}/project#{project.id}/-/merge_requests/#{merge_request.id}" } + let(:request) do + double(:Request, + path_parameters: { + controller: "projects/merge_requests", + action: "show", + namespace_id: group.name, + project_id: project.name, + id: merge_request.id.to_s + }, + protocol: 'http', + host: 'localhost', + query_string: '') + end before do - allow(Rails.application.routes).to receive(:recognize_path).and_return({ - controller: "projects/merge_requests", - action: "show", - namespace_id: group.name, - project_id: project.name, - id: merge_request.id.to_s - }) + allow(helper).to receive(:request).and_return(request) end it_behaves_like 'masked url' end context 'with controller for issue' do - let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/issues/#{issue.id}" } + let(:masked_url) { "http://localhost/namespace#{group.id}/project#{project.id}/-/issues/#{issue.id}" } + let(:request) do + double(:Request, + path_parameters: { + controller: "projects/issues", + action: "show", + namespace_id: group.name, + project_id: project.name, + id: issue.id.to_s + }, + protocol: 'http', + host: 'localhost', + query_string: '') + end before do - allow(Rails.application.routes).to receive(:recognize_path).and_return({ - controller: "projects/issues", - action: "show", - namespace_id: group.name, - project_id: project.name, - id: issue.id.to_s - }) + allow(helper).to receive(:request).and_return(request) end it_behaves_like 'masked url' end context 'with controller for groups with subgroups and project' do - let(:masked_url) { "http://test.host/namespace:#{subgroup.id}/project:#{subproject.id}"} + let(:masked_url) { "http://localhost/namespace#{subgroup.id}/project#{subproject.id}"} + let(:request) do + double(:Request, + path_parameters: { + controller: 'projects', + action: 'show', + namespace_id: subgroup.name, + id: subproject.name + }, + protocol: 'http', + host: 'localhost', + query_string: '') + end before do allow(helper).to receive(:group).and_return(subgroup) allow(helper).to receive(:project).and_return(subproject) - allow(Rails.application.routes).to receive(:recognize_path).and_return({ - controller: 'projects', - action: 'show', - namespace_id: subgroup.name, - id: subproject.name - }) + allow(helper).to receive(:request).and_return(request) end it_behaves_like 'masked url' end context 'with controller for groups and subgroups' do - let(:masked_url) { "http://test.host/namespace:#{subgroup.id}"} + let(:masked_url) { "http://localhost/groups/namespace#{subgroup.id}/-/shared"} + let(:request) do + double(:Request, + path_parameters: { + controller: 'groups', + action: 'show', + id: subgroup.name + }, + protocol: 'http', + host: 'localhost', + query_string: '') + end before do allow(helper).to receive(:group).and_return(subgroup) - allow(Rails.application.routes).to receive(:recognize_path).and_return({ - controller: 'groups', - action: 'show', - id: subgroup.name - }) + allow(helper).to receive(:request).and_return(request) end it_behaves_like 'masked url' end context 'with controller for blob with file path' do - let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/blob/:repository_path" } + let(:masked_url) { "http://localhost/namespace#{group.id}/project#{project.id}/-/blob/:repository_path" } + let(:request) do + double(:Request, + path_parameters: { + controller: 'projects/blob', + action: 'show', + namespace_id: group.name, + project_id: project.name, + id: 'master/README.md' + }, + protocol: 'http', + host: 'localhost', + query_string: '') + end + + before do + allow(helper).to receive(:request).and_return(request) + end + + it_behaves_like 'masked url' + end + + context 'when assignee_username is present' do + let(:masked_url) { "http://localhost/dashboard/issues?assignee_username=masked_assignee_username" } + let(:request) do + double(:Request, + path_parameters: { + controller: 'dashboard', + action: 'issues' + }, + protocol: 'http', + host: 'localhost', + query_string: 'assignee_username=root') + end before do - allow(Rails.application.routes).to receive(:recognize_path).and_return({ - controller: 'projects/blob', - action: 'show', - namespace_id: group.name, - project_id: project.name, - id: 'master/README.md' - }) + allow(helper).to receive(:request).and_return(request) end it_behaves_like 'masked url' end - context 'with non identifiable controller' do - let(:masked_url) { "http://test.host/dashboard/issues?assignee_username=root" } + context 'when author_username is present' do + let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=masked_scope&state=masked_state" } + let(:request) do + double(:Request, + path_parameters: { + controller: 'dashboard', + action: 'issues' + }, + protocol: 'http', + host: 'localhost', + query_string: 'author_username=root&scope=all&state=opened') + end before do - controller.request.path = '/dashboard/issues' - controller.request.query_string = 'assignee_username=root' - allow(Rails.application.routes).to receive(:recognize_path).and_return({ - controller: 'dashboard', - action: 'issues' - }) + allow(helper).to receive(:request).and_return(request) + end + + it_behaves_like 'masked url' + 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(:request) do + double(:Request, + path_parameters: { + controller: 'dashboard', + action: 'issues' + }, + protocol: 'http', + host: 'localhost', + query_string: 'author_username=root&scope=all&state=opened') + end + + before do + stub_const('Routing::PseudonymizationHelper::MaskHelper::QUERY_PARAMS_TO_NOT_MASK', %w[scope].freeze) + allow(helper).to receive(:request).and_return(request) + end + + it_behaves_like 'masked url' + end + + context 'when query string has keys with the same names as path params' do + let(:masked_url) { "http://localhost/dashboard/issues?action=masked_action&scope=masked_scope&state=masked_state" } + let(:request) do + double(:Request, + path_parameters: { + controller: 'dashboard', + action: 'issues' + }, + protocol: 'http', + host: 'localhost', + query_string: 'action=foobar&scope=all&state=opened') + end + + before do + allow(helper).to receive(:request).and_return(request) end it_behaves_like 'masked url' @@ -121,9 +222,13 @@ RSpec.describe ::Routing::PseudonymizationHelper do end describe 'when url has no params to mask' do - let(:root_url) { 'http://test.host' } + let(:root_url) { 'http://localhost/some/path' } context 'returns root url' do + before do + controller.request.path = 'some/path' + end + it 'masked_page_url' do expect(helper.masked_page_url).to eq(root_url) end @@ -132,17 +237,26 @@ RSpec.describe ::Routing::PseudonymizationHelper do describe 'when it raises exception' do context 'calls error tracking' do + let(:request) do + double(:Request, + path_parameters: { + controller: 'dashboard', + action: 'issues' + }, + protocol: 'http', + host: 'localhost', + query_string: 'assignee_username=root', + original_fullpath: '/dashboard/issues?assignee_username=root') + end + before do - controller.request.path = '/dashboard/issues' - controller.request.query_string = 'assignee_username=root' - allow(Rails.application.routes).to receive(:recognize_path).and_return({ - controller: 'dashboard', - action: 'issues' - }) + allow(helper).to receive(:request).and_return(request) end it 'sends error to sentry and returns nil' do - allow(helper).to receive(:mask_params).with(anything).and_raise(ActionController::RoutingError, 'Some routing error') + allow_next_instance_of(Routing::PseudonymizationHelper::MaskHelper) do |mask_helper| + allow(mask_helper).to receive(:mask_params).and_raise(ActionController::RoutingError, 'Some routing error') + end expect(Gitlab::ErrorTracking).to receive(:track_exception).with( ActionController::RoutingError, diff --git a/spec/helpers/storage_helper_spec.rb b/spec/helpers/storage_helper_spec.rb index 2cec7203fe1..d0646b30161 100644 --- a/spec/helpers/storage_helper_spec.rb +++ b/spec/helpers/storage_helper_spec.rb @@ -27,17 +27,18 @@ RSpec.describe StorageHelper do create(:project, namespace: namespace, statistics: build(:project_statistics, - namespace: namespace, - repository_size: 10.kilobytes, - wiki_size: 10.bytes, - lfs_objects_size: 20.gigabytes, - build_artifacts_size: 30.megabytes, - snippets_size: 40.megabytes, - packages_size: 12.megabytes, - uploads_size: 15.megabytes)) + namespace: namespace, + repository_size: 10.kilobytes, + wiki_size: 10.bytes, + lfs_objects_size: 20.gigabytes, + build_artifacts_size: 30.megabytes, + pipeline_artifacts_size: 11.megabytes, + snippets_size: 40.megabytes, + packages_size: 12.megabytes, + uploads_size: 15.megabytes)) end - let(:message) { 'Repository: 10 KB / Wikis: 10 Bytes / Build Artifacts: 30 MB / LFS: 20 GB / Snippets: 40 MB / Packages: 12 MB / Uploads: 15 MB' } + let(:message) { 'Repository: 10 KB / Wikis: 10 Bytes / Build Artifacts: 30 MB / Pipeline Artifacts: 11 MB / LFS: 20 GB / Snippets: 40 MB / Packages: 12 MB / Uploads: 15 MB' } it 'works on ProjectStatistics' do expect(helper.storage_counters_details(project.statistics)).to eq(message) diff --git a/spec/helpers/tab_helper_spec.rb b/spec/helpers/tab_helper_spec.rb index 346bfc7850c..e5e88466946 100644 --- a/spec/helpers/tab_helper_spec.rb +++ b/spec/helpers/tab_helper_spec.rb @@ -36,7 +36,15 @@ RSpec.describe TabHelper do expect(gl_tab_link_to('/url') { 'block content' }).to match(/block content/) end - it 'creates a tab with custom classes' do + it 'creates a tab with custom classes for enclosing list item without content block provided' do + expect(gl_tab_link_to('Link', '/url', { tab_class: 'my-class' })).to match(/
  • 1') + end + + context 'with extra classes' do + it 'creates a tab counter badge with the correct class attribute' do + expect(gl_tab_counter_badge(1, { class: 'js-test' })).to eq('1') + end + end + + context 'with data attributes' do + it 'creates a tab counter badge with the data attributes' do + expect(gl_tab_counter_badge(1, { data: { some_attribute: 'foo' } })).to eq('1') + end + end + end end diff --git a/spec/helpers/terms_helper_spec.rb b/spec/helpers/terms_helper_spec.rb new file mode 100644 index 00000000000..9120aad4627 --- /dev/null +++ b/spec/helpers/terms_helper_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TermsHelper do + let_it_be(:current_user) { build(:user) } + let_it_be(:terms) { build(:term) } + + before do + allow(helper).to receive(:current_user).and_return(current_user) + end + + describe '#terms_data' do + let_it_be(:redirect) { '%2F' } + let_it_be(:terms_markdown) { 'Lorem ipsum dolor sit amet' } + let_it_be(:accept_path) { '/-/users/terms/14/accept?redirect=%2F' } + let_it_be(:decline_path) { '/-/users/terms/14/decline?redirect=%2F' } + + subject(:result) { Gitlab::Json.parse(helper.terms_data(terms, redirect)) } + + it 'returns correct json' do + expect(helper).to receive(:markdown_field).with(terms, :terms).and_return(terms_markdown) + expect(helper).to receive(:can?).with(current_user, :accept_terms, terms).and_return(true) + expect(helper).to receive(:can?).with(current_user, :decline_terms, terms).and_return(true) + expect(helper).to receive(:accept_term_path).with(terms, { redirect: redirect }).and_return(accept_path) + expect(helper).to receive(:decline_term_path).with(terms, { redirect: redirect }).and_return(decline_path) + + expected = { + terms: terms_markdown, + permissions: { + can_accept: true, + can_decline: true + }, + paths: { + accept: accept_path, + decline: decline_path, + root: root_path + } + }.as_json + + expect(result).to eq(expected) + end + end +end diff --git a/spec/helpers/time_zone_helper_spec.rb b/spec/helpers/time_zone_helper_spec.rb index 43ad130c4b5..006fae5b814 100644 --- a/spec/helpers/time_zone_helper_spec.rb +++ b/spec/helpers/time_zone_helper_spec.rb @@ -100,4 +100,36 @@ RSpec.describe TimeZoneHelper, :aggregate_failures do end end end + + describe '#local_time_instance' do + let_it_be(:timezone) { 'UTC' } + + before do + travel_to Time.find_zone(timezone).local(2021, 7, 20, 15, 30, 45) + end + + context 'when timezone is `nil`' do + it 'returns the system timezone instance' do + expect(helper.local_time_instance(nil).name).to eq(timezone) + end + end + + context 'when timezone is blank' do + it 'returns the system timezone instance' do + expect(helper.local_time_instance('').name).to eq(timezone) + end + end + + context 'when a valid timezone is passed' do + it 'returns the local time instance' do + expect(helper.local_time_instance('America/Los_Angeles').name).to eq('America/Los_Angeles') + end + end + + context 'when an invalid timezone is passed' do + it 'returns the system timezone instance' do + expect(helper.local_time_instance('Foo/Bar').name).to eq(timezone) + end + end + end end diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb index f738ba855b8..7abc67e29a4 100644 --- a/spec/helpers/user_callouts_helper_spec.rb +++ b/spec/helpers/user_callouts_helper_spec.rb @@ -216,20 +216,6 @@ RSpec.describe UserCalloutsHelper do context 'when the invite_members_banner has not been dismissed' do it { is_expected.to eq(true) } - context 'when a user has dismissed this banner via cookies already' do - before do - helper.request.cookies["invite_#{group.id}_#{user.id}"] = 'true' - end - - it { is_expected.to eq(false) } - - it 'creates the callout from cookie', :aggregate_failures do - expect { subject }.to change { Users::GroupCallout.count }.by(1) - expect(Users::GroupCallout.last).to have_attributes(group_id: group.id, - feature_name: described_class::INVITE_MEMBERS_BANNER) - end - end - context 'when the group was just created' do before do flash[:notice] = "Group #{group.name} was successfully created" diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb index 480b1e2a0de..2b55319c70c 100644 --- a/spec/helpers/users_helper_spec.rb +++ b/spec/helpers/users_helper_spec.rb @@ -383,7 +383,7 @@ RSpec.describe UsersHelper do end context 'when `user.unconfirmed_email` is set' do - let(:user) { create(:user, unconfirmed_email: 'foo@bar.com') } + let(:user) { create(:user, :unconfirmed, unconfirmed_email: 'foo@bar.com') } it 'sets `modal_attributes.messageHtml` correctly' do expect(Gitlab::Json.parse(confirm_user_data[:modal_attributes])['messageHtml']).to eq('This user has an unconfirmed email address (foo@bar.com). You may force a confirmation.') diff --git a/spec/helpers/wiki_helper_spec.rb b/spec/helpers/wiki_helper_spec.rb index dc76f92db1b..0d04ca2b876 100644 --- a/spec/helpers/wiki_helper_spec.rb +++ b/spec/helpers/wiki_helper_spec.rb @@ -8,7 +8,7 @@ RSpec.describe WikiHelper do it 'sets the title for the show action' do expect(helper).to receive(:breadcrumb_title).with(page.human_title) - expect(helper).to receive(:wiki_breadcrumb_dropdown_links).with(page.slug) + expect(helper).to receive(:wiki_breadcrumb_collapsed_links).with(page.slug) expect(helper).to receive(:page_title).with(page.human_title, 'Wiki') expect(helper).to receive(:add_to_breadcrumbs).with('Wiki', helper.wiki_path(page.wiki)) @@ -17,7 +17,7 @@ RSpec.describe WikiHelper do it 'sets the title for a custom action' do expect(helper).to receive(:breadcrumb_title).with(page.human_title) - expect(helper).to receive(:wiki_breadcrumb_dropdown_links).with(page.slug) + expect(helper).to receive(:wiki_breadcrumb_collapsed_links).with(page.slug) expect(helper).to receive(:page_title).with('Edit', page.human_title, 'Wiki') expect(helper).to receive(:add_to_breadcrumbs).with('Wiki', helper.wiki_path(page.wiki)) @@ -27,7 +27,7 @@ RSpec.describe WikiHelper do it 'sets the title for an unsaved page' do expect(page).to receive(:persisted?).and_return(false) expect(helper).not_to receive(:breadcrumb_title) - expect(helper).not_to receive(:wiki_breadcrumb_dropdown_links) + expect(helper).not_to receive(:wiki_breadcrumb_collapsed_links) expect(helper).to receive(:page_title).with('Wiki') expect(helper).to receive(:add_to_breadcrumbs).with('Wiki', helper.wiki_path(page.wiki)) diff --git a/spec/initializers/0_postgresql_types_spec.rb b/spec/initializers/0_postgresql_types_spec.rb new file mode 100644 index 00000000000..76b243033d0 --- /dev/null +++ b/spec/initializers/0_postgresql_types_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'PostgreSQL registered types' do + subject(:types) { ApplicationRecord.connection.send(:type_map).keys } + + # These can be obtained via SELECT oid, typname from pg_type + it 'includes custom and standard OIDs' do + expect(types).to include(28, 194, 1034, 3220, 23, 20) + end + + it 'includes custom and standard types' do + expect(types).to include('xid', 'pg_node_tree', '_aclitem', 'pg_lsn', 'int4', 'int8') + end +end diff --git a/spec/initializers/100_patch_omniauth_oauth2_spec.rb b/spec/initializers/100_patch_omniauth_oauth2_spec.rb index 0c436e4ef45..c30a1cdeafa 100644 --- a/spec/initializers/100_patch_omniauth_oauth2_spec.rb +++ b/spec/initializers/100_patch_omniauth_oauth2_spec.rb @@ -2,12 +2,10 @@ require 'spec_helper' -RSpec.describe 'OmniAuth::Strategies::OAuth2', type: :strategy do - let(:strategy) { [OmniAuth::Strategies::OAuth2] } - +RSpec.describe 'OmniAuth::Strategies::OAuth2' do it 'verifies the gem version' do current_version = OmniAuth::OAuth2::VERSION - expected_version = '1.7.1' + expected_version = '1.7.2' expect(current_version).to eq(expected_version), <<~EOF New version #{current_version} of the `omniauth-oauth2` gem detected! @@ -18,39 +16,18 @@ RSpec.describe 'OmniAuth::Strategies::OAuth2', type: :strategy do EOF end - context 'when a custom error message is passed from an OAuth2 provider' do - let(:message) { 'Please go to https://evil.com' } - let(:state) { 'secret' } - let(:callback_path) { '/users/auth/oauth2/callback' } - let(:params) { { state: state, error: 'evil_key', error_description: message } } - let(:error) { last_request.env['omniauth.error'] } - - before do - env('rack.session', { 'omniauth.state' => state }) - end - - it 'returns the custom error message if the state is valid' do - get callback_path, **params - - expect(error.message).to eq("evil_key | #{message}") - end + context 'when a Faraday exception is raised' do + where(exception: [Faraday::TimeoutError, Faraday::ConnectionFailed]) - it 'returns the custom `error_reason` message if the `error_description` is blank' do - get callback_path, **params.merge(error_description: ' ', error_reason: 'custom reason') - - expect(error.message).to eq('evil_key | custom reason') - end - - it 'returns a CSRF error if the state is invalid' do - get callback_path, **params.merge(state: 'invalid') - - expect(error.message).to eq('csrf_detected | CSRF detected') - end + with_them do + it 'passes the exception to OmniAuth' do + instance = OmniAuth::Strategies::OAuth2.new(double) - it 'returns a CSRF error if the state is missing' do - get callback_path, **params.without(:state) + expect(instance).to receive(:original_callback_phase) { raise exception, 'message' } + expect(instance).to receive(:fail!).with(:timeout, kind_of(exception)) - expect(error.message).to eq('csrf_detected | CSRF detected') + instance.callback_phase + end end end end diff --git a/spec/initializers/carrierwave_patch_spec.rb b/spec/initializers/carrierwave_patch_spec.rb index e219db2299d..b0f337935ef 100644 --- a/spec/initializers/carrierwave_patch_spec.rb +++ b/spec/initializers/carrierwave_patch_spec.rb @@ -15,9 +15,6 @@ RSpec.describe 'CarrierWave::Storage::Fog::File' do subject { CarrierWave::Storage::Fog::File.new(uploader, storage, test_filename) } before do - require 'fog/azurerm' - require 'fog/aws' - stub_object_storage(connection_params: connection_options, remote_directory: bucket_name) allow(uploader).to receive(:fog_directory).and_return(bucket_name) diff --git a/spec/initializers/database_config_spec.rb b/spec/initializers/database_config_spec.rb index 23f7fd06254..230f1296760 100644 --- a/spec/initializers/database_config_spec.rb +++ b/spec/initializers/database_config_spec.rb @@ -7,56 +7,15 @@ RSpec.describe 'Database config initializer', :reestablished_active_record_base load Rails.root.join('config/initializers/database_config.rb') end - before do - allow(Gitlab::Runtime).to receive(:max_threads).and_return(max_threads) - end - - let(:max_threads) { 8 } - it 'retains the correct database name for the connection' do - previous_db_name = Gitlab::Database.main.scope.connection.pool.db_config.name + previous_db_name = ApplicationRecord.connection.pool.db_config.name subject - expect(Gitlab::Database.main.scope.connection.pool.db_config.name).to eq(previous_db_name) + expect(ApplicationRecord.connection.pool.db_config.name).to eq(previous_db_name) end - context 'when no custom headroom is specified' do - it 'sets the pool size based on the number of worker threads' do - old = ActiveRecord::Base.connection_db_config.pool - - expect(old).not_to eq(18) - - expect { subject } - .to change { ActiveRecord::Base.connection_db_config.pool } - .from(old) - .to(18) - end - - it 'overwrites custom pool settings' do - config = Gitlab::Database.main.config.merge(pool: 42) - - allow(Gitlab::Database.main).to receive(:config).and_return(config) - subject - - expect(ActiveRecord::Base.connection_db_config.pool).to eq(18) - end - end - - context "when specifying headroom through an ENV variable" do - let(:headroom) { 15 } - - before do - stub_env("DB_POOL_HEADROOM", headroom) - end - - it "adds headroom on top of the calculated size" do - old = ActiveRecord::Base.connection_db_config.pool - - expect { subject } - .to change { ActiveRecord::Base.connection_db_config.pool } - .from(old) - .to(23) - end + it 'does not overwrite custom pool settings' do + expect { subject }.not_to change { ActiveRecord::Base.connection_db_config.pool } end end diff --git a/spec/initializers/session_store_spec.rb b/spec/initializers/session_store_spec.rb new file mode 100644 index 00000000000..3da52ccc981 --- /dev/null +++ b/spec/initializers/session_store_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Session initializer for GitLab' do + subject { Gitlab::Application.config } + + let(:load_session_store) do + load Rails.root.join('config/initializers/session_store.rb') + end + + describe 'config#session_store' do + context 'when the GITLAB_REDIS_STORE_WITH_SESSION_STORE env is not set' do + before do + stub_env('GITLAB_REDIS_STORE_WITH_SESSION_STORE', nil) + end + + it 'initialized as a redis_store with a proper Redis::Store instance' do + expect(subject).to receive(:session_store).with(:redis_store, a_hash_including(redis_store: kind_of(::Redis::Store))) + + load_session_store + end + end + + context 'when the GITLAB_REDIS_STORE_WITH_SESSION_STORE env is disabled' do + before do + stub_env('GITLAB_REDIS_STORE_WITH_SESSION_STORE', false) + end + + it 'initialized as a redis_store with a proper servers configuration' do + expect(subject).to receive(:session_store).with(:redis_store, a_hash_including(servers: kind_of(Hash))) + + load_session_store + end + end + end +end diff --git a/spec/lib/api/ci/helpers/runner_spec.rb b/spec/lib/api/ci/helpers/runner_spec.rb index cc871d66d40..37277e7dcbd 100644 --- a/spec/lib/api/ci/helpers/runner_spec.rb +++ b/spec/lib/api/ci/helpers/runner_spec.rb @@ -15,7 +15,7 @@ RSpec.describe API::Ci::Helpers::Runner do it 'handles sticking of a build when a build ID is specified' do allow(helper).to receive(:params).and_return(id: build.id) - expect(ApplicationRecord.sticking) + expect(Ci::Build.sticking) .to receive(:stick_or_unstick_request) .with({}, :build, build.id) @@ -25,7 +25,7 @@ RSpec.describe API::Ci::Helpers::Runner do it 'does not handle sticking if no build ID was specified' do allow(helper).to receive(:params).and_return({}) - expect(ApplicationRecord.sticking) + expect(Ci::Build.sticking) .not_to receive(:stick_or_unstick_request) helper.current_job @@ -44,7 +44,7 @@ RSpec.describe API::Ci::Helpers::Runner do it 'handles sticking of a runner if a token is specified' do allow(helper).to receive(:params).and_return(token: runner.token) - expect(ApplicationRecord.sticking) + expect(Ci::Runner.sticking) .to receive(:stick_or_unstick_request) .with({}, :runner, runner.token) @@ -54,7 +54,7 @@ RSpec.describe API::Ci::Helpers::Runner do it 'does not handle sticking if no token was specified' do allow(helper).to receive(:params).and_return({}) - expect(ApplicationRecord.sticking) + expect(Ci::Runner.sticking) .not_to receive(:stick_or_unstick_request) helper.current_runner diff --git a/spec/lib/api/entities/projects/topic_spec.rb b/spec/lib/api/entities/projects/topic_spec.rb new file mode 100644 index 00000000000..cdf142dbb7d --- /dev/null +++ b/spec/lib/api/entities/projects/topic_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::Projects::Topic do + let(:topic) { create(:topic) } + + subject { described_class.new(topic).as_json } + + it 'exposes correct attributes' do + expect(subject).to include( + :id, + :name, + :description, + :total_projects_count, + :avatar_url + ) + end +end diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index 37e040a422b..2277bd78e86 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -351,12 +351,14 @@ RSpec.describe API::Helpers do let(:send_git_blob) do subject.send(:send_git_blob, repository, blob) + subject.header end before do allow(subject).to receive(:env).and_return({}) allow(subject).to receive(:content_type) allow(subject).to receive(:header).and_return({}) + allow(subject).to receive(:body).and_return('') allow(Gitlab::Workhorse).to receive(:send_git_blob) end diff --git a/spec/lib/atlassian/jira_connect/client_spec.rb b/spec/lib/atlassian/jira_connect/client_spec.rb index 5c8d4282118..9201d1c5dcb 100644 --- a/spec/lib/atlassian/jira_connect/client_spec.rb +++ b/spec/lib/atlassian/jira_connect/client_spec.rb @@ -18,7 +18,15 @@ RSpec.describe Atlassian::JiraConnect::Client do end end - describe '.generate_update_sequence_id' do + around do |example| + if example.metadata[:skip_freeze_time] + example.run + else + freeze_time { example.run } + end + end + + describe '.generate_update_sequence_id', :skip_freeze_time do it 'returns unix time in microseconds as integer', :aggregate_failures do travel_to(Time.utc(1970, 1, 1, 0, 0, 1)) do expect(described_class.generate_update_sequence_id).to eq(1000) diff --git a/spec/lib/banzai/filter/emoji_filter_spec.rb b/spec/lib/banzai/filter/emoji_filter_spec.rb index cb0b470eaa1..d621f63211b 100644 --- a/spec/lib/banzai/filter/emoji_filter_spec.rb +++ b/spec/lib/banzai/filter/emoji_filter_spec.rb @@ -28,9 +28,9 @@ RSpec.describe Banzai::Filter::EmojiFilter do it 'replaces name versions of trademark, copyright, and registered trademark' do doc = filter('

    :tm: :copyright: :registered:

    ') - expect(doc.css('gl-emoji')[0].text).to eq '™' - expect(doc.css('gl-emoji')[1].text).to eq '©' - expect(doc.css('gl-emoji')[2].text).to eq '®' + expect(doc.css('gl-emoji')[0].text).to eq '™️' + expect(doc.css('gl-emoji')[1].text).to eq '©️' + expect(doc.css('gl-emoji')[2].text).to eq '®️' end it 'correctly encodes the URL' do diff --git a/spec/lib/banzai/filter/footnote_filter_spec.rb b/spec/lib/banzai/filter/footnote_filter_spec.rb index 01b7319fab1..54faa748d53 100644 --- a/spec/lib/banzai/filter/footnote_filter_spec.rb +++ b/spec/lib/banzai/filter/footnote_filter_spec.rb @@ -5,34 +5,42 @@ require 'spec_helper' RSpec.describe Banzai::Filter::FootnoteFilter do include FilterSpecHelper - # first[^1] and second[^second] + # rubocop:disable Style/AsciiComments + # first[^1] and second[^second] and third[^_😄_] # [^1]: one # [^second]: two + # [^_😄_]: three + # rubocop:enable Style/AsciiComments let(:footnote) do - <<~EOF -

    first1 and second2

    -

    same reference1

    + <<~EOF.strip_heredoc +

    first1 and second2 and third3

    +
      -
    1. -

      one

      +
    2. +

      one

    3. -
    4. -

      two

      +
    5. +

      two

      +
    6. \n
    7. +

      three

    EOF end let(:filtered_footnote) do - <<~EOF -

    first1 and second2

    -

    same reference1

    -
      -
    1. -

      one

      + <<~EOF.strip_heredoc +

      first1 and second2 and third3

      + +
        +
      1. +

        one

        +
      2. +
      3. +

        two

      4. -
      5. -

        two

        +
      6. +

        three

      EOF @@ -41,10 +49,56 @@ RSpec.describe Banzai::Filter::FootnoteFilter do context 'when footnotes exist' do let(:doc) { filter(footnote) } let(:link_node) { doc.css('sup > a').first } - let(:identifier) { link_node[:id].delete_prefix('fnref1-') } + let(:identifier) { link_node[:id].delete_prefix('fnref-1-') } it 'properly adds the necessary ids and classes' do expect(doc.to_html).to eq filtered_footnote end + + context 'using ruby-based HTML renderer' do + # first[^1] and second[^second] + # [^1]: one + # [^second]: two + let(:footnote) do + <<~EOF +

      first1 and second2

      +

      same reference1

      +
        +
      1. +

        one

        +
      2. +
      3. +

        two

        +
      4. +
      + EOF + end + + let(:filtered_footnote) do + <<~EOF +

      first1 and second2

      +

      same reference1

      +
        +
      1. +

        one

        +
      2. +
      3. +

        two

        +
      4. +
      + EOF + end + + let(:doc) { filter(footnote) } + let(:identifier) { link_node[:id].delete_prefix('fnref1-') } + + before do + stub_feature_flags(use_cmark_renderer: false) + end + + it 'properly adds the necessary ids and classes' do + expect(doc.to_html).to eq filtered_footnote + end + end end end diff --git a/spec/lib/banzai/filter/markdown_filter_spec.rb b/spec/lib/banzai/filter/markdown_filter_spec.rb index c5e84a0c1e7..a310de5c015 100644 --- a/spec/lib/banzai/filter/markdown_filter_spec.rb +++ b/spec/lib/banzai/filter/markdown_filter_spec.rb @@ -5,90 +5,125 @@ require 'spec_helper' RSpec.describe Banzai::Filter::MarkdownFilter do include FilterSpecHelper - describe 'markdown engine from context' do - it 'defaults to CommonMark' do - expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance| - expect(instance).to receive(:render).and_return('test') + shared_examples_for 'renders correct markdown' do + describe 'markdown engine from context' do + it 'defaults to CommonMark' do + expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance| + expect(instance).to receive(:render).and_return('test') + end + + filter('test') end - filter('test') - end + it 'uses CommonMark' do + expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance| + expect(instance).to receive(:render).and_return('test') + end - it 'uses CommonMark' do - expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance| - expect(instance).to receive(:render).and_return('test') + filter('test', { markdown_engine: :common_mark }) end - - filter('test', { markdown_engine: :common_mark }) end - end - describe 'code block' do - context 'using CommonMark' do - before do - stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark) + describe 'code block' do + context 'using CommonMark' do + before do + stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark) + end + + it 'adds language to lang attribute when specified' do + result = filter("```html\nsome code\n```", no_sourcepos: true) + + if Feature.enabled?(:use_cmark_renderer) + expect(result).to start_with('
      ')
      +          else
      +            expect(result).to start_with('
      ')
      +          end
      +        end
      +
      +        it 'does not add language to lang attribute when not specified' do
      +          result = filter("```\nsome code\n```", no_sourcepos: true)
      +
      +          expect(result).to start_with('
      ')
      +        end
      +
      +        it 'works with utf8 chars in language' do
      +          result = filter("```日\nsome code\n```", no_sourcepos: true)
      +
      +          if Feature.enabled?(:use_cmark_renderer)
      +            expect(result).to start_with('
      ')
      +          else
      +            expect(result).to start_with('
      ')
      +          end
      +        end
      +
      +        it 'works with additional language parameters' do
      +          result = filter("```ruby:red gem foo\nsome code\n```", no_sourcepos: true)
      +
      +          if Feature.enabled?(:use_cmark_renderer)
      +            expect(result).to start_with('
      ')
      +          else
      +            expect(result).to start_with('
      ')
      +          end
      +        end
             end
      +    end
       
      -      it 'adds language to lang attribute when specified' do
      -        result = filter("```html\nsome code\n```", no_sourcepos: true)
      -
      -        expect(result).to start_with('
      ')
      -      end
      -
      -      it 'does not add language to lang attribute when not specified' do
      -        result = filter("```\nsome code\n```", no_sourcepos: true)
      -
      -        expect(result).to start_with('
      ')
      -      end
      +    describe 'source line position' do
      +      context 'using CommonMark' do
      +        before do
      +          stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark)
      +        end
       
      -      it 'works with utf8 chars in language' do
      -        result = filter("```日\nsome code\n```", no_sourcepos: true)
      +        it 'defaults to add data-sourcepos' do
      +          result = filter('test')
       
      -        expect(result).to start_with('
      ')
      -      end
      +          expect(result).to eq '

      test

      ' + end - it 'works with additional language parameters' do - result = filter("```ruby:red gem\nsome code\n```", no_sourcepos: true) + it 'disables data-sourcepos' do + result = filter('test', no_sourcepos: true) - expect(result).to start_with('
      ')
      +          expect(result).to eq '

      test

      ' + end end end - end - describe 'source line position' do - context 'using CommonMark' do - before do - stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark) - end + describe 'footnotes in tables' do + it 'processes footnotes in table cells' do + text = <<-MD.strip_heredoc + | Column1 | + | --------- | + | foot [^1] | - it 'defaults to add data-sourcepos' do - result = filter('test') + [^1]: a footnote + MD - expect(result).to eq '

      test

      ' - end + result = filter(text, no_sourcepos: true) - it 'disables data-sourcepos' do - result = filter('test', no_sourcepos: true) + expect(result).to include('foot test

      ' + if Feature.enabled?(:use_cmark_renderer) + expect(result).to include('
      ') + else + expect(result).to include('
      ') + end end end end - describe 'footnotes in tables' do - it 'processes footnotes in table cells' do - text = <<-MD.strip_heredoc - | Column1 | - | --------- | - | foot [^1] | - - [^1]: a footnote - MD + context 'using ruby-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: false) + end - result = filter(text, no_sourcepos: true) + it_behaves_like 'renders correct markdown' + end - expect(result).to include('foot ') + context 'using c-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: true) end + + it_behaves_like 'renders correct markdown' end end diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb index 5ad94c74514..d1a3b5689a8 100644 --- a/spec/lib/banzai/filter/plantuml_filter_spec.rb +++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb @@ -5,30 +5,67 @@ require 'spec_helper' RSpec.describe Banzai::Filter::PlantumlFilter do include FilterSpecHelper - it 'replaces plantuml pre tag with img tag' do - stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080") - input = '
      Bob -> Sara : Hello
      ' - output = '
      ' - doc = filter(input) + shared_examples_for 'renders correct markdown' do + it 'replaces plantuml pre tag with img tag' do + stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080") - expect(doc.to_s).to eq output + input = if Feature.enabled?(:use_cmark_renderer) + '
      Bob -> Sara : Hello
      ' + else + '
      Bob -> Sara : Hello
      ' + end + + output = '
      ' + doc = filter(input) + + expect(doc.to_s).to eq output + end + + it 'does not replace plantuml pre tag with img tag if disabled' do + stub_application_setting(plantuml_enabled: false) + + if Feature.enabled?(:use_cmark_renderer) + input = '
      Bob -> Sara : Hello
      ' + output = '
      Bob -> Sara : Hello
      ' + else + input = '
      Bob -> Sara : Hello
      ' + output = '
      Bob -> Sara : Hello
      ' + end + + doc = filter(input) + + expect(doc.to_s).to eq output + end + + it 'does not replace plantuml pre tag with img tag if url is invalid' do + stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") + + input = if Feature.enabled?(:use_cmark_renderer) + '
      Bob -> Sara : Hello
      ' + else + '
      Bob -> Sara : Hello
      ' + end + + output = '
       Error: cannot connect to PlantUML server at "invalid"
      ' + doc = filter(input) + + expect(doc.to_s).to eq output + end end - it 'does not replace plantuml pre tag with img tag if disabled' do - stub_application_setting(plantuml_enabled: false) - input = '
      Bob -> Sara : Hello
      ' - output = '
      Bob -> Sara : Hello
      ' - doc = filter(input) + context 'using ruby-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: false) + end - expect(doc.to_s).to eq output + it_behaves_like 'renders correct markdown' end - it 'does not replace plantuml pre tag with img tag if url is invalid' do - stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") - input = '
      Bob -> Sara : Hello
      ' - output = '
       Error: cannot connect to PlantUML server at "invalid"
      ' - doc = filter(input) + context 'using c-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: true) + end - expect(doc.to_s).to eq output + it_behaves_like 'renders correct markdown' end end diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index f880fe06ce3..8eb8e5cf800 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -45,10 +45,10 @@ RSpec.describe Banzai::Filter::SanitizationFilter do it 'allows `text-align` property in `style` attribute on table elements' do html = <<~HTML - - - -
      Head
      Body
      + + + +
      Head
      Body
      HTML doc = filter(html) @@ -140,14 +140,14 @@ RSpec.describe Banzai::Filter::SanitizationFilter do describe 'footnotes' do it 'allows correct footnote id property on links' do - exp = %q(foo/bar.md) + exp = %q(foo/bar.md) act = filter(exp) expect(act.to_html).to eq exp end it 'allows correct footnote id property on li element' do - exp = %q(
      1. footnote
      ) + exp = %q(
      1. footnote
      ) act = filter(exp) expect(act.to_html).to eq exp @@ -156,7 +156,7 @@ RSpec.describe Banzai::Filter::SanitizationFilter do it 'removes invalid id for footnote links' do exp = %q(link) - %w[fnrefx test xfnref1].each do |id| + %w[fnrefx test xfnref-1].each do |id| act = filter(%(link)) expect(act.to_html).to eq exp @@ -166,18 +166,58 @@ RSpec.describe Banzai::Filter::SanitizationFilter do it 'removes invalid id for footnote li' do exp = %q(
      1. footnote
      ) - %w[fnx test xfn1].each do |id| + %w[fnx test xfn-1].each do |id| act = filter(%(
      1. footnote
      )) expect(act.to_html).to eq exp end end - it 'allows footnotes numbered higher than 9' do - exp = %q(link
      1. footnote
      ) - act = filter(exp) + context 'using ruby-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: false) + end - expect(act.to_html).to eq exp + it 'allows correct footnote id property on links' do + exp = %q(foo/bar.md) + act = filter(exp) + + expect(act.to_html).to eq exp + end + + it 'allows correct footnote id property on li element' do + exp = %q(
      1. footnote
      ) + act = filter(exp) + + expect(act.to_html).to eq exp + end + + it 'removes invalid id for footnote links' do + exp = %q(link) + + %w[fnrefx test xfnref1].each do |id| + act = filter(%(link)) + + expect(act.to_html).to eq exp + end + end + + it 'removes invalid id for footnote li' do + exp = %q(
      1. footnote
      ) + + %w[fnx test xfn1].each do |id| + act = filter(%(
      1. footnote
      )) + + expect(act.to_html).to eq exp + end + end + + it 'allows footnotes numbered higher than 9' do + exp = %q(link
      1. footnote
      ) + act = filter(exp) + + expect(act.to_html).to eq exp + end end end end diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb index 7e45ecdd135..dfe022b51d2 100644 --- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb +++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb @@ -11,130 +11,210 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do # after Markdown rendering. result = filter(%{
      <script>alert(1)</script>
      }) - expect(result.to_html).not_to include("") - expect(result.to_html).to include("alert(1)") + # `(1)` symbols are wrapped by lexer tags. + expect(result.to_html).not_to match(%r{" + end end - context "when #{lang} has extra params" do - let(:lang_params) { 'foo-bar-kux' } + context 'when multiple param delimiters are used' do + let(:lang) { 'suggestion' } + let(:lang_params) { '-1+10' } - it "includes data-lang-params tag with extra information" do - result = filter(%{
      This is a test
      }) + let(:expected_result) do + %{
      This is a test
      } + end - expect(result.to_html).to eq(%{
      This is a test
      }) + context 'when delimiter is space' do + it 'delimits on the first appearance' do + if Feature.enabled?(:use_cmark_renderer) + result = filter(%{
      This is a test
      }) + + expect(result.to_html).to eq(expected_result) + else + result = filter(%{
      This is a test
      }) + + expect(result.to_html).to eq(%{
      This is a test
      }) + end + end end - include_examples "XSS prevention", lang - include_examples "XSS prevention", - "#{lang}#{described_class::PARAMS_DELIMITER}<script>alert(1)</script>" - include_examples "XSS prevention", - "#{lang}#{described_class::PARAMS_DELIMITER}" + context 'when delimiter is colon' do + it 'delimits on the first appearance' do + result = filter(%{
      This is a test
      }) + + if Feature.enabled?(:use_cmark_renderer) + expect(result.to_html).to eq(expected_result) + else + expect(result.to_html).to eq(%{
      This is a test
      }) + end + end + end end end - context 'when multiple param delimiters are used' do - let(:lang) { 'suggestion' } - let(:lang_params) { '-1+10' } + context "when sourcepos metadata is available" do + it "includes it in the highlighted code block" do + result = filter('
      This is a test
      ') - it "delimits on the first appearance" do - result = filter(%{
      This is a test
      }) - - expect(result.to_html).to eq(%{
      This is a test
      }) + expect(result.to_html).to eq('
      This is a test
      ') end end - end - context "when sourcepos metadata is available" do - it "includes it in the highlighted code block" do - result = filter('
      This is a test
      ') + context "when Rouge lexing fails" do + before do + allow_next_instance_of(Rouge::Lexers::Ruby) do |instance| + allow(instance).to receive(:stream_tokens).and_raise(StandardError) + end + end - expect(result.to_html).to eq('
      This is a test
      ') - end - end + it "highlights as plaintext" do + result = if Feature.enabled?(:use_cmark_renderer) + filter('
      This is a test
      ') + else + filter('
      This is a test
      ') + end - context "when Rouge lexing fails" do - before do - allow_next_instance_of(Rouge::Lexers::Ruby) do |instance| - allow(instance).to receive(:stream_tokens).and_raise(StandardError) + expect(result.to_html).to eq('
      This is a test
      ') end + + include_examples "XSS prevention", "ruby" end - it "highlights as plaintext" do - result = filter('
      This is a test
      ') + context "when Rouge lexing fails after a retry" do + before do + allow_next_instance_of(Rouge::Lexers::PlainText) do |instance| + allow(instance).to receive(:stream_tokens).and_raise(StandardError) + end + end - expect(result.to_html).to eq('
      This is a test
      ') - end + it "does not add highlighting classes" do + result = filter('
      This is a test
      ') + + expect(result.to_html).to eq('
      This is a test
      ') + end - include_examples "XSS prevention", "ruby" + include_examples "XSS prevention", "ruby" + end end - context "when Rouge lexing fails after a retry" do + context 'using ruby-based HTML renderer' do before do - allow_next_instance_of(Rouge::Lexers::PlainText) do |instance| - allow(instance).to receive(:stream_tokens).and_raise(StandardError) - end + stub_feature_flags(use_cmark_renderer: false) end - it "does not add highlighting classes" do - result = filter('
      This is a test
      ') + it_behaves_like 'renders correct markdown' + end - expect(result.to_html).to eq('
      This is a test
      ') + context 'using c-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: true) end - include_examples "XSS prevention", "ruby" + it_behaves_like 'renders correct markdown' end end diff --git a/spec/lib/banzai/pipeline/emoji_pipeline_spec.rb b/spec/lib/banzai/pipeline/emoji_pipeline_spec.rb index 6de9d65f1b2..8103846d4f7 100644 --- a/spec/lib/banzai/pipeline/emoji_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/emoji_pipeline_spec.rb @@ -3,18 +3,20 @@ require 'spec_helper' RSpec.describe Banzai::Pipeline::EmojiPipeline do + let(:emoji) { TanukiEmoji.find_by_alpha_code('100') } + def parse(text) described_class.to_html(text, {}) end it 'replaces emoji' do - expected_result = "Hello world #{Gitlab::Emoji.gl_emoji_tag('100')}" + expected_result = "Hello world #{Gitlab::Emoji.gl_emoji_tag(emoji)}" expect(parse('Hello world :100:')).to eq(expected_result) end it 'filters out HTML tags' do - expected_result = "Hello <b>world</b> #{Gitlab::Emoji.gl_emoji_tag('100')}" + expected_result = "Hello <b>world</b> #{Gitlab::Emoji.gl_emoji_tag(emoji)}" expect(parse('Hello world :100:')).to eq(expected_result) end diff --git a/spec/lib/banzai/pipeline/full_pipeline_spec.rb b/spec/lib/banzai/pipeline/full_pipeline_spec.rb index 7a335fad3f8..01bca7b23e8 100644 --- a/spec/lib/banzai/pipeline/full_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/full_pipeline_spec.rb @@ -31,29 +31,29 @@ RSpec.describe Banzai::Pipeline::FullPipeline do describe 'footnotes' do let(:project) { create(:project, :public) } let(:html) { described_class.to_html(footnote_markdown, project: project) } - let(:identifier) { html[/.*fnref1-(\d+).*/, 1] } + let(:identifier) { html[/.*fnref-1-(\d+).*/, 1] } let(:footnote_markdown) do <<~EOF - first[^1] and second[^second] and twenty[^twenty] + first[^1] and second[^😄second] and twenty[^_twenty] [^1]: one - [^second]: two - [^twenty]: twenty + [^😄second]: two + [^_twenty]: twenty EOF end let(:filtered_footnote) do - <<~EOF -

      first1 and second2 and twenty3

      + <<~EOF.strip_heredoc +

      first1 and second2 and twenty3

      -
        -
      1. -

        one

        +
          +
        1. +

          one

        2. -
        3. -

          two

          +
        4. +

          two

        5. -
        6. -

          twenty

          +
        7. +

          twenty

        EOF @@ -64,6 +64,47 @@ RSpec.describe Banzai::Pipeline::FullPipeline do expect(html.lines.map(&:strip).join("\n")).to eq filtered_footnote end + + context 'using ruby-based HTML renderer' do + let(:html) { described_class.to_html(footnote_markdown, project: project) } + let(:identifier) { html[/.*fnref1-(\d+).*/, 1] } + let(:footnote_markdown) do + <<~EOF + first[^1] and second[^second] and twenty[^twenty] + [^1]: one + [^second]: two + [^twenty]: twenty + EOF + end + + let(:filtered_footnote) do + <<~EOF +

        first1 and second2 and twenty3

        + +
          +
        1. +

          one

          +
        2. +
        3. +

          two

          +
        4. +
        5. +

          twenty

          +
        6. +
        + EOF + end + + before do + stub_feature_flags(use_cmark_renderer: false) + end + + it 'properly adds the necessary ids and classes' do + stub_commonmark_sourcepos_disabled + + expect(html.lines.map(&:strip).join("\n")).to eq filtered_footnote + end + end end describe 'links are detected as malicious' do diff --git a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb index 4903f624469..394fcc06eba 100644 --- a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb @@ -5,18 +5,7 @@ require 'spec_helper' RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do using RSpec::Parameterized::TableSyntax - describe 'backslash escapes' do - let_it_be(:project) { create(:project, :public) } - let_it_be(:issue) { create(:issue, project: project) } - - def correct_html_included(markdown, expected) - result = described_class.call(markdown, {}) - - expect(result[:output].to_html).to include(expected) - - result - end - + shared_examples_for 'renders correct markdown' do describe 'CommonMark tests', :aggregate_failures do it 'converts all reference punctuation to literals' do reference_chars = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS @@ -32,7 +21,7 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do expect(result[:escaped_literals]).to be_truthy end - it 'ensure we handle all the GitLab reference characters' do + it 'ensure we handle all the GitLab reference characters', :eager_load do reference_chars = ObjectSpace.each_object(Class).map do |klass| next unless klass.included_modules.include?(Referable) next unless klass.respond_to?(:reference_prefix) @@ -79,10 +68,19 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do end describe 'work in all other contexts, including URLs and link titles, link references, and info strings in fenced code blocks' do + let(:markdown) { %Q(``` foo\\@bar\nfoo\n```) } + + it 'renders correct html' do + if Feature.enabled?(:use_cmark_renderer) + correct_html_included(markdown, %Q(
        foo\n
        )) + else + correct_html_included(markdown, %Q(foo\n)) + end + end + where(:markdown, :expected) do %q![foo](/bar\@ "\@title")! | %q(foo) %Q![foo]\n\n[foo]: /bar\\@ "\\@title"! | %q(foo) - %Q(``` foo\\@bar\nfoo\n```) | %Q(foo\n) end with_them do @@ -91,4 +89,33 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do end end end + + describe 'backslash escapes' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue) { create(:issue, project: project) } + + def correct_html_included(markdown, expected) + result = described_class.call(markdown, {}) + + expect(result[:output].to_html).to include(expected) + + result + end + + context 'using ruby-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: false) + end + + it_behaves_like 'renders correct markdown' + end + + context 'using c-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: true) + end + + it_behaves_like 'renders correct markdown' + end + end end diff --git a/spec/lib/banzai/renderer_spec.rb b/spec/lib/banzai/renderer_spec.rb index 52bf3087875..d487268da78 100644 --- a/spec/lib/banzai/renderer_spec.rb +++ b/spec/lib/banzai/renderer_spec.rb @@ -84,6 +84,24 @@ RSpec.describe Banzai::Renderer do end end + describe '#cacheless_render' do + context 'without cache' do + let(:object) { fake_object(fresh: false) } + let(:histogram) { double('prometheus histogram') } + + it 'returns cacheless render field' do + allow(renderer).to receive(:render_result).and_return(output: 'test') + allow(renderer).to receive(:real_duration_histogram).and_return(histogram) + allow(renderer).to receive(:cpu_duration_histogram).and_return(histogram) + + expect(renderer).to receive(:render_result).with('test', {}) + expect(histogram).to receive(:observe).twice + + renderer.cacheless_render('test') + end + end + end + describe '#post_process' do let(:context_options) { {} } let(:html) { 'Consequatur aperiam et nesciunt modi aut assumenda quo id. '} diff --git a/spec/lib/bulk_imports/common/pipelines/milestones_pipeline_spec.rb b/spec/lib/bulk_imports/common/pipelines/milestones_pipeline_spec.rb new file mode 100644 index 00000000000..9f71175f46f --- /dev/null +++ b/spec/lib/bulk_imports/common/pipelines/milestones_pipeline_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Common::Pipelines::MilestonesPipeline do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:bulk_import) { create(:bulk_import, user: user) } + let(:tracker) { create(:bulk_import_tracker, entity: entity) } + let(:context) { BulkImports::Pipeline::Context.new(tracker) } + let(:source_project_id) { nil } # if set, then exported_milestone is a project milestone + let(:source_group_id) { nil } # if set, then exported_milestone is a group milestone + let(:exported_milestone_for_project) do + exported_milestone_for_group.merge( + 'events' => [{ + 'project_id' => source_project_id, + 'author_id' => 9, + 'created_at' => "2021-08-12T19:12:49.810Z", + 'updated_at' => "2021-08-12T19:12:49.810Z", + 'target_type' => "Milestone", + 'group_id' => source_group_id, + 'fingerprint' => 'f270eb9b27d0', + 'id' => 66, + 'action' => "created" + }] + ) + end + + let(:exported_milestone_for_group) do + { + 'id' => 1, + 'title' => "v1.0", + 'project_id' => source_project_id, + 'description' => "Amet velit repellat ut rerum aut cum.", + 'due_date' => "2019-11-22", + 'created_at' => "2019-11-20T17:02:14.296Z", + 'updated_at' => "2019-11-20T17:02:14.296Z", + 'state' => "active", + 'iid' => 2, + 'start_date' => "2019-11-21", + 'group_id' => source_group_id + } + end + + before do + group.add_owner(user) + + allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: exported_milestones)) + end + end + + subject { described_class.new(context) } + + shared_examples 'bulk_imports milestones pipeline' do + let(:tested_entity) { nil } + + describe '#run' do + it 'imports milestones into destination' do + expect { subject.run }.to change(Milestone, :count).by(1) + + imported_milestone = tested_entity.milestones.first + + expect(imported_milestone.title).to eq("v1.0") + expect(imported_milestone.description).to eq("Amet velit repellat ut rerum aut cum.") + expect(imported_milestone.due_date.to_s).to eq("2019-11-22") + expect(imported_milestone.created_at).to eq("2019-11-20T17:02:14.296Z") + expect(imported_milestone.updated_at).to eq("2019-11-20T17:02:14.296Z") + expect(imported_milestone.start_date.to_s).to eq("2019-11-21") + end + end + + describe '#load' do + context 'when milestone is not persisted' do + it 'saves the milestone' do + milestone = build(:milestone, group: group) + + expect(milestone).to receive(:save!) + + subject.load(context, milestone) + end + end + + context 'when milestone is persisted' do + it 'does not save milestone' do + milestone = create(:milestone, group: group) + + expect(milestone).not_to receive(:save!) + + subject.load(context, milestone) + end + end + + context 'when milestone is missing' do + it 'returns' do + expect(subject.load(context, nil)).to be_nil + end + end + end + end + + context 'group milestone' do + let(:exported_milestones) { [[exported_milestone_for_group, 0]] } + let(:entity) do + create( + :bulk_import_entity, + group: group, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Group', + destination_namespace: group.full_path + ) + end + + it_behaves_like 'bulk_imports milestones pipeline' do + let(:tested_entity) { group } + let(:source_group_id) { 1 } + end + end + + context 'project milestone' do + let(:project) { create(:project, group: group) } + let(:exported_milestones) { [[exported_milestone_for_project, 0]] } + + let(:entity) do + create( + :bulk_import_entity, + :project_entity, + project: project, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Project', + destination_namespace: group.full_path + ) + end + + it_behaves_like 'bulk_imports milestones pipeline' do + let(:tested_entity) { project } + let(:source_project_id) { 1 } + + it 'imports events' do + subject.run + + imported_event = tested_entity.milestones.first.events.first + + expect(imported_event.created_at).to eq("2021-08-12T19:12:49.810Z") + expect(imported_event.updated_at).to eq("2021-08-12T19:12:49.810Z") + expect(imported_event.target_type).to eq("Milestone") + expect(imported_event.fingerprint).to eq("f270eb9b27d0") + expect(imported_event.action).to eq("created") + end + end + end +end diff --git a/spec/lib/bulk_imports/common/pipelines/uploads_pipeline_spec.rb b/spec/lib/bulk_imports/common/pipelines/uploads_pipeline_spec.rb new file mode 100644 index 00000000000..a3cc866a406 --- /dev/null +++ b/spec/lib/bulk_imports/common/pipelines/uploads_pipeline_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Common::Pipelines::UploadsPipeline do + let_it_be(:tmpdir) { Dir.mktmpdir } + let_it_be(:project) { create(:project) } + let_it_be(:entity) { create(:bulk_import_entity, :project_entity, project: project, source_full_path: 'test') } + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + let_it_be(:uploads_dir_path) { File.join(tmpdir, '72a497a02fe3ee09edae2ed06d390038') } + let_it_be(:upload_file_path) { File.join(uploads_dir_path, 'upload.txt')} + + subject(:pipeline) { described_class.new(context) } + + before do + stub_uploads_object_storage(FileUploader) + + FileUtils.mkdir_p(uploads_dir_path) + FileUtils.touch(upload_file_path) + end + + after do + FileUtils.remove_entry(tmpdir) if Dir.exist?(tmpdir) + end + + describe '#run' do + it 'imports uploads into destination portable and removes tmpdir' do + allow(Dir).to receive(:mktmpdir).with('bulk_imports').and_return(tmpdir) + allow(pipeline).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: [upload_file_path])) + + pipeline.run + + expect(project.uploads.map { |u| u.retrieve_uploader.filename }).to include('upload.txt') + + expect(Dir.exist?(tmpdir)).to eq(false) + end + end + + describe '#extract' do + it 'downloads & extracts upload paths' do + allow(Dir).to receive(:mktmpdir).and_return(tmpdir) + expect(pipeline).to receive(:untar_zxf) + file_download_service = instance_double("BulkImports::FileDownloadService") + + expect(BulkImports::FileDownloadService) + .to receive(:new) + .with( + configuration: context.configuration, + relative_url: "/projects/test/export_relations/download?relation=uploads", + dir: tmpdir, + filename: 'uploads.tar.gz') + .and_return(file_download_service) + + expect(file_download_service).to receive(:execute) + + extracted_data = pipeline.extract(context) + + expect(extracted_data.data).to contain_exactly(uploads_dir_path, upload_file_path) + end + end + + describe '#load' do + it 'creates a file upload' do + expect { pipeline.load(context, upload_file_path) }.to change { project.uploads.count }.by(1) + end + + context 'when dynamic path is nil' do + it 'returns' do + expect { pipeline.load(context, File.join(tmpdir, 'test')) }.not_to change { project.uploads.count } + end + end + + context 'when path is a directory' do + it 'returns' do + expect { pipeline.load(context, uploads_dir_path) }.not_to change { project.uploads.count } + end + end + end +end diff --git a/spec/lib/bulk_imports/common/pipelines/wiki_pipeline_spec.rb b/spec/lib/bulk_imports/common/pipelines/wiki_pipeline_spec.rb new file mode 100644 index 00000000000..0eefb7390dc --- /dev/null +++ b/spec/lib/bulk_imports/common/pipelines/wiki_pipeline_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Common::Pipelines::WikiPipeline do + describe '#run' do + let_it_be(:user) { create(:user) } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + let_it_be(:parent) { create(:project) } + + let_it_be(:entity) do + create( + :bulk_import_entity, + :project_entity, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Wiki', + destination_namespace: parent.full_path, + project: parent + ) + end + + it_behaves_like 'wiki pipeline imports a wiki for an entity' + end +end diff --git a/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb deleted file mode 100644 index 7a0f964c5f3..00000000000 --- a/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe BulkImports::Groups::Graphql::GetMilestonesQuery do - it 'has a valid query' do - tracker = create(:bulk_import_tracker) - context = BulkImports::Pipeline::Context.new(tracker) - - query = GraphQL::Query.new( - GitlabSchema, - described_class.to_s, - variables: described_class.variables(context) - ) - result = GitlabSchema.static_validator.validate(query) - - expect(result[:errors]).to be_empty - end - - describe '#data_path' do - it 'returns data path' do - expected = %w[data group milestones nodes] - - expect(described_class.data_path).to eq(expected) - end - end - - describe '#page_info_path' do - it 'returns pagination information path' do - expected = %w[data group milestones page_info] - - expect(described_class.page_info_path).to eq(expected) - end - end -end diff --git a/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb b/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb index de0b56045b3..69363bf0866 100644 --- a/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb +++ b/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb @@ -11,20 +11,66 @@ RSpec.describe BulkImports::Groups::Loaders::GroupLoader do let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } let(:service_double) { instance_double(::Groups::CreateService) } - let(:data) { { foo: :bar } } + let(:data) { { 'path' => 'test' } } subject { described_class.new } + context 'when path is missing' do + it 'raises an error' do + expect { subject.load(context, {}) }.to raise_error(described_class::GroupCreationError, 'Path is missing') + end + end + + context 'when destination namespace is not a group' do + it 'raises an error' do + entity.update!(destination_namespace: user.namespace.path) + + expect { subject.load(context, data) }.to raise_error(described_class::GroupCreationError, 'Destination is not a group') + end + end + + context 'when group exists' do + it 'raises an error' do + group1 = create(:group) + group2 = create(:group, parent: group1) + entity.update!(destination_namespace: group1.full_path) + data = { 'path' => group2.path } + + expect { subject.load(context, data) }.to raise_error(described_class::GroupCreationError, 'Group exists') + end + end + + context 'when there are other group errors' do + it 'raises an error with those errors' do + group = ::Group.new + group.validate + expected_errors = group.errors.full_messages.to_sentence + + expect(::Groups::CreateService) + .to receive(:new) + .with(context.current_user, data) + .and_return(service_double) + + expect(service_double).to receive(:execute).and_return(group) + expect(entity).not_to receive(:update!) + + expect { subject.load(context, data) }.to raise_error(described_class::GroupCreationError, expected_errors) + end + end + context 'when user can create group' do shared_examples 'calls Group Create Service to create a new group' do it 'calls Group Create Service to create a new group' do + group_double = instance_double(::Group) + expect(::Groups::CreateService) .to receive(:new) .with(context.current_user, data) .and_return(service_double) - expect(service_double).to receive(:execute) - expect(entity).to receive(:update!) + expect(service_double).to receive(:execute).and_return(group_double) + expect(group_double).to receive(:errors).and_return([]) + expect(entity).to receive(:update!).with(group: group_double) subject.load(context, data) end @@ -40,7 +86,7 @@ RSpec.describe BulkImports::Groups::Loaders::GroupLoader do context 'when there is parent group' do let(:parent) { create(:group) } - let(:data) { { 'parent_id' => parent.id } } + let(:data) { { 'parent_id' => parent.id, 'path' => 'test' } } before do allow(Ability).to receive(:allowed?).with(user, :create_subgroup, parent).and_return(true) @@ -55,7 +101,7 @@ RSpec.describe BulkImports::Groups::Loaders::GroupLoader do it 'does not create new group' do expect(::Groups::CreateService).not_to receive(:new) - subject.load(context, data) + expect { subject.load(context, data) }.to raise_error(described_class::GroupCreationError, 'User not allowed to create group') end end @@ -69,7 +115,7 @@ RSpec.describe BulkImports::Groups::Loaders::GroupLoader do context 'when there is parent group' do let(:parent) { create(:group) } - let(:data) { { 'parent_id' => parent.id } } + let(:data) { { 'parent_id' => parent.id, 'path' => 'test' } } before do allow(Ability).to receive(:allowed?).with(user, :create_subgroup, parent).and_return(false) diff --git a/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb deleted file mode 100644 index a8354e62459..00000000000 --- a/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe BulkImports::Groups::Pipelines::MilestonesPipeline do - let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:bulk_import) { create(:bulk_import, user: user) } - let_it_be(:filepath) { 'spec/fixtures/bulk_imports/gz/milestones.ndjson.gz' } - let_it_be(:entity) do - create( - :bulk_import_entity, - group: group, - bulk_import: bulk_import, - source_full_path: 'source/full/path', - destination_name: 'My Destination Group', - destination_namespace: group.full_path - ) - end - - let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } - let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } - - let(:tmpdir) { Dir.mktmpdir } - - before do - FileUtils.copy_file(filepath, File.join(tmpdir, 'milestones.ndjson.gz')) - group.add_owner(user) - end - - subject { described_class.new(context) } - - describe '#run' do - it 'imports group milestones into destination group and removes tmpdir' do - allow(Dir).to receive(:mktmpdir).and_return(tmpdir) - allow_next_instance_of(BulkImports::FileDownloadService) do |service| - allow(service).to receive(:execute) - end - - expect { subject.run }.to change(Milestone, :count).by(5) - expect(group.milestones.pluck(:title)).to contain_exactly('v4.0', 'v3.0', 'v2.0', 'v1.0', 'v0.0') - expect(File.directory?(tmpdir)).to eq(false) - end - end - - describe '#load' do - context 'when milestone is not persisted' do - it 'saves the milestone' do - milestone = build(:milestone, group: group) - - expect(milestone).to receive(:save!) - - subject.load(context, milestone) - end - end - - context 'when milestone is persisted' do - it 'does not save milestone' do - milestone = create(:milestone, group: group) - - expect(milestone).not_to receive(:save!) - - subject.load(context, milestone) - end - end - - context 'when milestone is missing' do - it 'returns' do - expect(subject.load(context, nil)).to be_nil - end - end - end -end diff --git a/spec/lib/bulk_imports/groups/stage_spec.rb b/spec/lib/bulk_imports/groups/stage_spec.rb index b322b7b0edf..5719acac4d7 100644 --- a/spec/lib/bulk_imports/groups/stage_spec.rb +++ b/spec/lib/bulk_imports/groups/stage_spec.rb @@ -12,7 +12,7 @@ RSpec.describe BulkImports::Groups::Stage do [1, BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline], [1, BulkImports::Groups::Pipelines::MembersPipeline], [1, BulkImports::Common::Pipelines::LabelsPipeline], - [1, BulkImports::Groups::Pipelines::MilestonesPipeline], + [1, BulkImports::Common::Pipelines::MilestonesPipeline], [1, BulkImports::Groups::Pipelines::BadgesPipeline], [2, BulkImports::Common::Pipelines::BoardsPipeline] ] diff --git a/spec/lib/bulk_imports/ndjson_pipeline_spec.rb b/spec/lib/bulk_imports/ndjson_pipeline_spec.rb index 7d156c2c3df..c5197fb29d9 100644 --- a/spec/lib/bulk_imports/ndjson_pipeline_spec.rb +++ b/spec/lib/bulk_imports/ndjson_pipeline_spec.rb @@ -111,6 +111,7 @@ RSpec.describe BulkImports::NdjsonPipeline do context = double(portable: group, current_user: user, import_export_config: config, bulk_import: import_double, entity: entity_double) allow(subject).to receive(:import_export_config).and_return(config) allow(subject).to receive(:context).and_return(context) + relation_object = double expect(Gitlab::ImportExport::Group::RelationFactory) .to receive(:create) @@ -124,6 +125,8 @@ RSpec.describe BulkImports::NdjsonPipeline do user: user, excluded_keys: nil ) + .and_return(relation_object) + expect(relation_object).to receive(:assign_attributes).with(group: group) subject.transform(context, data) end diff --git a/spec/lib/bulk_imports/projects/pipelines/external_pull_requests_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/external_pull_requests_pipeline_spec.rb new file mode 100644 index 00000000000..8f610fcc2ae --- /dev/null +++ b/spec/lib/bulk_imports/projects/pipelines/external_pull_requests_pipeline_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Pipelines::ExternalPullRequestsPipeline do + let_it_be(:project) { create(:project) } + let_it_be(:bulk_import) { create(:bulk_import) } + let_it_be(:entity) { create(:bulk_import_entity, :project_entity, project: project, bulk_import: bulk_import) } + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + + let(:attributes) { {} } + let(:external_pr) { project.external_pull_requests.last } + let(:external_pull_request) do + { + 'pull_request_iid' => 4, + 'source_branch' => 'feature', + 'target_branch' => 'main', + 'source_repository' => 'repository', + 'target_repository' => 'repository', + 'source_sha' => 'abc', + 'target_sha' => 'xyz', + 'status' => 'open', + 'created_at' => '2019-12-24T14:04:50.053Z', + 'updated_at' => '2019-12-24T14:05:18.138Z' + }.merge(attributes) + end + + subject(:pipeline) { described_class.new(context) } + + describe '#run' do + before do + allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor| + allow(extractor).to receive(:remove_tmp_dir) + allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: [[external_pull_request, 0]])) + end + + pipeline.run + end + + it 'imports external pull request', :aggregate_failures do + expect(external_pr.pull_request_iid).to eq(external_pull_request['pull_request_iid']) + expect(external_pr.source_branch).to eq(external_pull_request['source_branch']) + expect(external_pr.target_branch).to eq(external_pull_request['target_branch']) + expect(external_pr.status).to eq(external_pull_request['status']) + expect(external_pr.created_at).to eq(external_pull_request['created_at']) + expect(external_pr.updated_at).to eq(external_pull_request['updated_at']) + end + + context 'when status is closed' do + let(:attributes) { { 'status' => 'closed' } } + + it 'imports closed external pull request' do + expect(external_pr.status).to eq(attributes['status']) + end + end + + context 'when from fork' do + let(:attributes) { { 'source_repository' => 'source' } } + + it 'does not create external pull request' do + expect(external_pr).to be_nil + end + end + end +end diff --git a/spec/lib/bulk_imports/projects/pipelines/merge_requests_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/merge_requests_pipeline_spec.rb new file mode 100644 index 00000000000..3f02356b41e --- /dev/null +++ b/spec/lib/bulk_imports/projects/pipelines/merge_requests_pipeline_spec.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Pipelines::MergeRequestsPipeline do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, group: group) } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + let_it_be(:entity) do + create( + :bulk_import_entity, + :project_entity, + project: project, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Project', + destination_namespace: group.full_path + ) + end + + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + + let(:mr) do + { + 'iid' => 7, + 'author_id' => 22, + 'source_project_id' => 1234, + 'target_project_id' => 1234, + 'title' => 'Imported MR', + 'description' => 'Description', + 'state' => 'opened', + 'source_branch' => 'feature', + 'target_branch' => 'main', + 'source_branch_sha' => 'ABCD', + 'target_branch_sha' => 'DCBA', + 'created_at' => '2020-06-14T15:02:47.967Z', + 'updated_at' => '2020-06-14T15:03:47.967Z', + 'merge_request_diff' => { + 'state' => 'collected', + 'base_commit_sha' => 'ae73cb07c9eeaf35924a10f713b364d32b2dd34f', + 'head_commit_sha' => 'a97f74ddaa848b707bea65441c903ae4bf5d844d', + 'start_commit_sha' => '9eea46b5c72ead701c22f516474b95049c9d9462', + 'merge_request_diff_commits' => [ + { + 'sha' => 'COMMIT1', + 'relative_order' => 0, + 'message' => 'commit message', + 'authored_date' => '2014-08-06T08:35:52.000+02:00', + 'committed_date' => '2014-08-06T08:35:52.000+02:00', + 'commit_author' => { + 'name' => 'Commit Author', + 'email' => 'gitlab@example.com' + }, + 'committer' => { + 'name' => 'Committer', + 'email' => 'committer@example.com' + } + } + ], + 'merge_request_diff_files' => [ + { + 'relative_order' => 0, + 'utf8_diff' => '--- a/.gitignore\n+++ b/.gitignore\n@@ -1 +1 @@ test\n', + 'new_path' => '.gitignore', + 'old_path' => '.gitignore', + 'a_mode' => '100644', + 'b_mode' => '100644', + 'new_file' => false, + 'renamed_file' => false, + 'deleted_file' => false, + 'too_large' => false + } + ] + } + }.merge(attributes) + end + + let(:attributes) { {} } + let(:imported_mr) { project.merge_requests.find_by_title(mr['title']) } + + subject(:pipeline) { described_class.new(context) } + + describe '#run' do + before do + group.add_owner(user) + + allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor| + allow(extractor).to receive(:remove_tmp_dir) + allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: [[mr, 0]])) + end + + allow(project.repository).to receive(:fetch_source_branch!).and_return(true) + allow(project.repository).to receive(:branch_exists?).and_return(false) + allow(project.repository).to receive(:create_branch) + + pipeline.run + end + + it 'imports a merge request' do + expect(project.merge_requests.count).to eq(1) + expect(imported_mr.title).to eq(mr['title']) + expect(imported_mr.description).to eq(mr['description']) + expect(imported_mr.state).to eq(mr['state']) + expect(imported_mr.iid).to eq(mr['iid']) + expect(imported_mr.created_at).to eq(mr['created_at']) + expect(imported_mr.updated_at).to eq(mr['updated_at']) + expect(imported_mr.author).to eq(user) + end + + context 'merge request state' do + context 'when mr is closed' do + let(:attributes) { { 'state' => 'closed' } } + + it 'imported mr as closed' do + expect(imported_mr.state).to eq(attributes['state']) + end + end + + context 'when mr is merged' do + let(:attributes) { { 'state' => 'merged' } } + + it 'imported mr as merged' do + expect(imported_mr.state).to eq(attributes['state']) + end + end + end + + context 'source & target project' do + it 'has the new project as target' do + expect(imported_mr.target_project).to eq(project) + end + + it 'has the new project as source' do + expect(imported_mr.source_project).to eq(project) + end + + context 'when source/target projects differ' do + let(:attributes) { { 'source_project_id' => 4321 } } + + it 'has no source' do + expect(imported_mr.source_project).to be_nil + end + + context 'when diff_head_sha is present' do + let(:attributes) { { 'diff_head_sha' => 'HEAD', 'source_project_id' => 4321 } } + + it 'has the new project as source' do + expect(imported_mr.source_project).to eq(project) + end + end + end + end + + context 'resource label events' do + let(:attributes) { { 'resource_label_events' => [{ 'action' => 'add', 'user_id' => 1 }] } } + + it 'restores resource label events' do + expect(imported_mr.resource_label_events.first.action).to eq('add') + end + end + + context 'award emoji' do + let(:attributes) { { 'award_emoji' => [{ 'name' => 'tada', 'user_id' => 22 }] } } + + it 'has award emoji' do + expect(imported_mr.award_emoji.first.name).to eq(attributes['award_emoji'].first['name']) + end + end + + context 'notes' do + let(:note) { imported_mr.notes.first } + let(:attributes) do + { + 'notes' => [ + { + 'note' => 'Issue note', + 'note_html' => '

        something else entirely

        ', + 'cached_markdown_version' => 917504, + 'author_id' => 22, + 'author' => { 'name' => 'User 22' }, + 'created_at' => '2016-06-14T15:02:56.632Z', + 'updated_at' => '2016-06-14T15:02:47.770Z', + 'award_emoji' => [{ 'name' => 'clapper', 'user_id' => 22 }] + } + ] + } + end + + it 'imports mr note' do + expect(note).to be_present + expect(note.note).to include('By User 22') + expect(note.note).to include(attributes['notes'].first['note']) + expect(note.author).to eq(user) + end + + it 'has award emoji' do + emoji = note.award_emoji.first + + expect(emoji.name).to eq('clapper') + expect(emoji.user).to eq(user) + end + + it 'does not import note_html' do + expect(note.note_html).to match(attributes['notes'].first['note']) + expect(note.note_html).not_to match(attributes['notes'].first['note_html']) + end + end + + context 'system note metadata' do + let(:attributes) do + { + 'notes' => [ + { + 'note' => 'added 3 commits', + 'system' => true, + 'author_id' => 22, + 'author' => { 'name' => 'User 22' }, + 'created_at' => '2016-06-14T15:02:56.632Z', + 'updated_at' => '2016-06-14T15:02:47.770Z', + 'system_note_metadata' => { 'action' => 'commit', 'commit_count' => 3 } + } + ] + } + end + + it 'restores system note metadata' do + note = imported_mr.notes.first + + expect(note.system).to eq(true) + expect(note.noteable_type).to eq('MergeRequest') + expect(note.system_note_metadata.action).to eq('commit') + expect(note.system_note_metadata.commit_count).to eq(3) + end + end + + context 'diffs' do + it 'imports merge request diff' do + expect(imported_mr.merge_request_diff).to be_present + end + + it 'has the correct data for merge request latest_merge_request_diff' do + expect(imported_mr.latest_merge_request_diff_id).to eq(imported_mr.merge_request_diffs.maximum(:id)) + end + + it 'imports diff files' do + expect(imported_mr.merge_request_diff.merge_request_diff_files.count).to eq(1) + end + + context 'diff commits' do + it 'imports diff commits' do + expect(imported_mr.merge_request_diff.merge_request_diff_commits.count).to eq(1) + end + + it 'assigns committer and author details to diff commits' do + commit = imported_mr.merge_request_diff.merge_request_diff_commits.first + + expect(commit.commit_author_id).not_to be_nil + expect(commit.committer_id).not_to be_nil + end + + it 'assigns the correct commit users to diff commits' do + commit = MergeRequestDiffCommit.find_by(sha: 'COMMIT1') + + expect(commit.commit_author.name).to eq('Commit Author') + expect(commit.commit_author.email).to eq('gitlab@example.com') + expect(commit.committer.name).to eq('Committer') + expect(commit.committer.email).to eq('committer@example.com') + end + end + end + + context 'labels' do + let(:attributes) do + { + 'label_links' => [ + { 'label' => { 'title' => 'imported label 1', 'type' => 'ProjectLabel' } }, + { 'label' => { 'title' => 'imported label 2', 'type' => 'ProjectLabel' } } + ] + } + end + + it 'imports labels' do + expect(imported_mr.labels.pluck(:title)).to contain_exactly('imported label 1', 'imported label 2') + end + end + + context 'milestone' do + let(:attributes) { { 'milestone' => { 'title' => 'imported milestone' } } } + + it 'imports milestone' do + expect(imported_mr.milestone.title).to eq(attributes.dig('milestone', 'title')) + end + end + end +end diff --git a/spec/lib/bulk_imports/projects/pipelines/protected_branches_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/protected_branches_pipeline_spec.rb new file mode 100644 index 00000000000..7de2e266192 --- /dev/null +++ b/spec/lib/bulk_imports/projects/pipelines/protected_branches_pipeline_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Pipelines::ProtectedBranchesPipeline do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + let_it_be(:entity) { create(:bulk_import_entity, :project_entity, project: project, bulk_import: bulk_import) } + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + let_it_be(:protected_branch) do + { + 'name' => 'main', + 'created_at' => '2016-06-14T15:02:47.967Z', + 'updated_at' => '2016-06-14T15:02:47.967Z', + 'merge_access_levels' => [ + { + 'access_level' => 40, + 'created_at' => '2016-06-15T15:02:47.967Z', + 'updated_at' => '2016-06-15T15:02:47.967Z' + } + ], + 'push_access_levels' => [ + { + 'access_level' => 30, + 'created_at' => '2016-06-16T15:02:47.967Z', + 'updated_at' => '2016-06-16T15:02:47.967Z' + } + ] + } + end + + subject(:pipeline) { described_class.new(context) } + + describe '#run' do + it 'imports protected branch information' do + allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: [protected_branch, 0])) + end + + pipeline.run + + imported_protected_branch = project.protected_branches.last + merge_access_level = imported_protected_branch.merge_access_levels.first + push_access_level = imported_protected_branch.push_access_levels.first + + aggregate_failures do + expect(imported_protected_branch.name).to eq(protected_branch['name']) + expect(imported_protected_branch.updated_at).to eq(protected_branch['updated_at']) + expect(imported_protected_branch.created_at).to eq(protected_branch['created_at']) + expect(merge_access_level.access_level).to eq(protected_branch['merge_access_levels'].first['access_level']) + expect(merge_access_level.created_at).to eq(protected_branch['merge_access_levels'].first['created_at']) + expect(merge_access_level.updated_at).to eq(protected_branch['merge_access_levels'].first['updated_at']) + expect(push_access_level.access_level).to eq(protected_branch['push_access_levels'].first['access_level']) + expect(push_access_level.created_at).to eq(protected_branch['push_access_levels'].first['created_at']) + expect(push_access_level.updated_at).to eq(protected_branch['push_access_levels'].first['updated_at']) + end + end + end +end diff --git a/spec/lib/bulk_imports/projects/pipelines/repository_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/repository_pipeline_spec.rb index af39ec7a11c..583485faf8d 100644 --- a/spec/lib/bulk_imports/projects/pipelines/repository_pipeline_spec.rb +++ b/spec/lib/bulk_imports/projects/pipelines/repository_pipeline_spec.rb @@ -3,71 +3,72 @@ require 'spec_helper' RSpec.describe BulkImports::Projects::Pipelines::RepositoryPipeline do - describe '#run' do - let_it_be(:user) { create(:user) } - let_it_be(:parent) { create(:project) } - let_it_be(:bulk_import) { create(:bulk_import, user: user) } - let_it_be(:bulk_import_configuration) { create(:bulk_import_configuration, bulk_import: bulk_import) } - - let_it_be(:entity) do - create( - :bulk_import_entity, - :project_entity, - bulk_import: bulk_import, - source_full_path: 'source/full/path', - destination_name: 'My Destination Repository', - destination_namespace: parent.full_path, - project: parent - ) - end + let_it_be(:user) { create(:user) } + let_it_be(:parent) { create(:project) } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + let_it_be(:bulk_import_configuration) { create(:bulk_import_configuration, bulk_import: bulk_import) } + + let_it_be(:entity) do + create( + :bulk_import_entity, + :project_entity, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Repository', + destination_namespace: parent.full_path, + project: parent + ) + end - let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } - let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } - context 'successfully imports repository' do - let(:project_data) do - { - 'httpUrlToRepo' => 'http://test.git' - } - end + let(:extracted_data) { BulkImports::Pipeline::ExtractedData.new(data: project_data) } - subject { described_class.new(context) } + subject(:pipeline) { described_class.new(context) } + + before do + allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(extracted_data) + end + end + + describe '#run' do + context 'successfully imports repository' do + let(:project_data) { { 'httpUrlToRepo' => 'http://test.git' } } it 'imports new repository into destination project' do - allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| - allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: project_data)) - end + url = project_data['httpUrlToRepo'].sub("://", "://oauth2:#{bulk_import_configuration.access_token}@") - expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| - url = project_data['httpUrlToRepo'].sub("://", "://oauth2:#{bulk_import_configuration.access_token}@") - expect(repository_service).to receive(:import_repository).with(url).and_return 0 - end + expect(context.portable).to receive(:ensure_repository) + expect(context.portable.repository).to receive(:fetch_as_mirror).with(url) - subject.run + pipeline.run end end context 'blocked local networks' do - let(:project_data) do - { - 'httpUrlToRepo' => 'http://localhost/foo.git' - } - end + let(:project_data) { { 'httpUrlToRepo' => 'http://localhost/foo.git' } } - before do + it 'imports new repository into destination project' do allow(Gitlab.config.gitlab).to receive(:host).and_return('notlocalhost.gitlab.com') allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_web_hooks_and_services?).and_return(false) - allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| - allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: project_data)) - end - end - subject { described_class.new(context) } + pipeline.run - it 'imports new repository into destination project' do - subject.run - expect(context.entity.failed?).to be_truthy + expect(context.entity.failed?).to eq(true) end end end + + describe '#after_run' do + it 'executes housekeeping service after import' do + service = instance_double(Repositories::HousekeepingService) + + expect(Repositories::HousekeepingService).to receive(:new).with(context.portable, :gc).and_return(service) + expect(service).to receive(:execute) + + pipeline.after_run(context) + end + end end diff --git a/spec/lib/bulk_imports/projects/stage_spec.rb b/spec/lib/bulk_imports/projects/stage_spec.rb index c606cf7c556..e7670085f60 100644 --- a/spec/lib/bulk_imports/projects/stage_spec.rb +++ b/spec/lib/bulk_imports/projects/stage_spec.rb @@ -8,9 +8,15 @@ RSpec.describe BulkImports::Projects::Stage do [0, BulkImports::Projects::Pipelines::ProjectPipeline], [1, BulkImports::Projects::Pipelines::RepositoryPipeline], [2, BulkImports::Common::Pipelines::LabelsPipeline], + [2, BulkImports::Common::Pipelines::MilestonesPipeline], [3, BulkImports::Projects::Pipelines::IssuesPipeline], [4, BulkImports::Common::Pipelines::BoardsPipeline], - [5, BulkImports::Common::Pipelines::EntityFinisher] + [4, BulkImports::Projects::Pipelines::MergeRequestsPipeline], + [4, BulkImports::Projects::Pipelines::ExternalPullRequestsPipeline], + [4, BulkImports::Projects::Pipelines::ProtectedBranchesPipeline], + [5, BulkImports::Common::Pipelines::WikiPipeline], + [5, BulkImports::Common::Pipelines::UploadsPipeline], + [6, BulkImports::Common::Pipelines::EntityFinisher] ] end @@ -22,7 +28,8 @@ RSpec.describe BulkImports::Projects::Stage do describe '#pipelines' do it 'list all the pipelines with their stage number, ordered by stage' do - expect(subject.pipelines).to eq(pipelines) + expect(subject.pipelines & pipelines).to contain_exactly(*pipelines) + expect(subject.pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher) end end end diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb index 47a8fcf5dd0..259d7d5ad13 100644 --- a/spec/lib/container_registry/client_spec.rb +++ b/spec/lib/container_registry/client_spec.rb @@ -279,7 +279,7 @@ RSpec.describe ContainerRegistry::Client do it 'uploads the manifest and returns the digest' do stub_request(:put, "http://container-registry/v2/path/manifests/tagA") .with(body: "{\n \"foo\": \"bar\"\n}", headers: manifest_headers) - .to_return(status: 200, body: "", headers: { 'docker-content-digest' => 'sha256:123' }) + .to_return(status: 200, body: "", headers: { DependencyProxy::Manifest::DIGEST_HEADER => 'sha256:123' }) expect_new_faraday(timeout: false) diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb index d6e6b254dd9..9b931ab6dbc 100644 --- a/spec/lib/container_registry/tag_spec.rb +++ b/spec/lib/container_registry/tag_spec.rb @@ -213,7 +213,7 @@ RSpec.describe ContainerRegistry::Tag do before do stub_request(:head, 'http://registry.gitlab/v2/group/test/manifests/tag') .with(headers: headers) - .to_return(status: 200, headers: { 'Docker-Content-Digest' => 'sha256:digest' }) + .to_return(status: 200, headers: { DependencyProxy::Manifest::DIGEST_HEADER => 'sha256:digest' }) end describe '#digest' do diff --git a/spec/lib/error_tracking/collector/payload_validator_spec.rb b/spec/lib/error_tracking/collector/payload_validator_spec.rb new file mode 100644 index 00000000000..852cf9eac6c --- /dev/null +++ b/spec/lib/error_tracking/collector/payload_validator_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ErrorTracking::Collector::PayloadValidator do + describe '#valid?' do + RSpec.shared_examples 'valid payload' do + it 'returns true' do + expect(described_class.new.valid?(payload)).to be_truthy + end + end + + RSpec.shared_examples 'invalid payload' do + it 'returns false' do + expect(described_class.new.valid?(payload)).to be_falsey + end + end + + context 'ruby payload' do + let(:payload) { Gitlab::Json.parse(fixture_file('error_tracking/parsed_event.json')) } + + it_behaves_like 'valid payload' + end + + context 'python payload' do + let(:payload) { Gitlab::Json.parse(fixture_file('error_tracking/python_event.json')) } + + it_behaves_like 'valid payload' + end + + context 'browser payload' do + let(:payload) { Gitlab::Json.parse(fixture_file('error_tracking/browser_event.json')) } + + it_behaves_like 'valid payload' + end + + context 'empty payload' do + let(:payload) { '' } + + it_behaves_like 'invalid payload' + end + + context 'invalid payload' do + let(:payload) { { 'foo' => 'bar' } } + + it_behaves_like 'invalid payload' + end + end +end diff --git a/spec/lib/error_tracking/collector/sentry_request_parser_spec.rb b/spec/lib/error_tracking/collector/sentry_request_parser_spec.rb index 6f12c6d25e0..06f4b64ce93 100644 --- a/spec/lib/error_tracking/collector/sentry_request_parser_spec.rb +++ b/spec/lib/error_tracking/collector/sentry_request_parser_spec.rb @@ -33,12 +33,5 @@ RSpec.describe ErrorTracking::Collector::SentryRequestParser do context 'plain text sentry request' do it_behaves_like 'valid parser' end - - context 'gzip encoded sentry request' do - let(:headers) { { 'Content-Encoding' => 'gzip' } } - let(:body) { Zlib.gzip(raw_event) } - - it_behaves_like 'valid parser' - end end end diff --git a/spec/lib/feature/gitaly_spec.rb b/spec/lib/feature/gitaly_spec.rb index 311589c3253..ed80e31e3cd 100644 --- a/spec/lib/feature/gitaly_spec.rb +++ b/spec/lib/feature/gitaly_spec.rb @@ -78,7 +78,9 @@ RSpec.describe Feature::Gitaly do context 'when table does not exist' do before do - allow(::Gitlab::Database.main).to receive(:cached_table_exists?).and_return(false) + allow(Feature::FlipperFeature.database) + .to receive(:cached_table_exists?) + .and_return(false) end it 'returns an empty Hash' do diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb index 9d4820f9a4c..58e7292c125 100644 --- a/spec/lib/feature_spec.rb +++ b/spec/lib/feature_spec.rb @@ -102,12 +102,14 @@ RSpec.describe Feature, stub_feature_flags: false do describe '.flipper' do context 'when request store is inactive' do - it 'memoizes the Flipper instance' do + it 'memoizes the Flipper instance but does not not enable Flipper memoization' do expect(Flipper).to receive(:new).once.and_call_original 2.times do - described_class.send(:flipper) + described_class.flipper end + + expect(described_class.flipper.adapter.memoizing?).to eq(false) end end @@ -115,9 +117,11 @@ RSpec.describe Feature, stub_feature_flags: false do it 'memoizes the Flipper instance' do expect(Flipper).to receive(:new).once.and_call_original - described_class.send(:flipper) + described_class.flipper described_class.instance_variable_set(:@flipper, nil) - described_class.send(:flipper) + described_class.flipper + + expect(described_class.flipper.adapter.memoizing?).to eq(true) end end end @@ -310,7 +314,7 @@ RSpec.describe Feature, stub_feature_flags: false do context 'when database exists' do before do - allow(Gitlab::Database.main).to receive(:exists?).and_return(true) + allow(ApplicationRecord.database).to receive(:exists?).and_return(true) end it 'checks the persisted status and returns false' do @@ -322,7 +326,7 @@ RSpec.describe Feature, stub_feature_flags: false do context 'when database does not exist' do before do - allow(Gitlab::Database.main).to receive(:exists?).and_return(false) + allow(ApplicationRecord.database).to receive(:exists?).and_return(false) end it 'returns false without checking the status in the database' do diff --git a/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb b/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb index 05833cf4ec4..b67425ae012 100644 --- a/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb +++ b/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb @@ -99,4 +99,15 @@ RSpec.describe Gitlab::UsageMetricDefinitionGenerator, :silence_stdout do expect(YAML.safe_load(File.read(metric_definition_path))).to include("name" => "some name") end end + + context 'with multiple file names' do + let(:key_paths) { ['counts_weekly.test_metric', 'counts_weekly.test1_metric'] } + + it 'creates multiple files' do + described_class.new(key_paths, { 'dir' => dir }).invoke_all + files = Dir.glob(File.join(temp_dir, 'metrics/counts_7d/*_metric.yml')) + + expect(files.count).to eq(2) + end + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/aggregated/base_query_builder_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/aggregated/base_query_builder_spec.rb new file mode 100644 index 00000000000..bf2f8d8159b --- /dev/null +++ b/spec/lib/gitlab/analytics/cycle_analytics/aggregated/base_query_builder_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Analytics::CycleAnalytics::Aggregated::BaseQueryBuilder do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:user_1) { create(:user) } + + let_it_be(:label_1) { create(:label, project: project) } + let_it_be(:label_2) { create(:label, project: project) } + + let_it_be(:issue_1) { create(:issue, project: project, author: project.creator, labels: [label_1, label_2]) } + let_it_be(:issue_2) { create(:issue, project: project, milestone: milestone, assignees: [user_1]) } + let_it_be(:issue_3) { create(:issue, project: project) } + let_it_be(:issue_outside_project) { create(:issue) } + + let_it_be(:stage) do + create(:cycle_analytics_project_stage, + project: project, + start_event_identifier: :issue_created, + end_event_identifier: :issue_deployed_to_production + ) + end + + let_it_be(:stage_event_1) do + create(:cycle_analytics_issue_stage_event, + stage_event_hash_id: stage.stage_event_hash_id, + group_id: group.id, + project_id: project.id, + issue_id: issue_1.id, + author_id: project.creator.id, + milestone_id: nil, + state_id: issue_1.state_id, + end_event_timestamp: 8.months.ago + ) + end + + let_it_be(:stage_event_2) do + create(:cycle_analytics_issue_stage_event, + stage_event_hash_id: stage.stage_event_hash_id, + group_id: group.id, + project_id: project.id, + issue_id: issue_2.id, + author_id: nil, + milestone_id: milestone.id, + state_id: issue_2.state_id + ) + end + + let_it_be(:stage_event_3) do + create(:cycle_analytics_issue_stage_event, + stage_event_hash_id: stage.stage_event_hash_id, + group_id: group.id, + project_id: project.id, + issue_id: issue_3.id, + author_id: nil, + milestone_id: milestone.id, + state_id: issue_3.state_id, + start_event_timestamp: 8.months.ago, + end_event_timestamp: nil + ) + end + + let(:params) do + { + from: 1.year.ago.to_date, + to: Date.today + } + end + + subject(:issue_ids) { described_class.new(stage: stage, params: params).build.pluck(:issue_id) } + + it 'scopes the query for the given project' do + expect(issue_ids).to match_array([issue_1.id, issue_2.id]) + expect(issue_ids).not_to include([issue_outside_project.id]) + end + + describe 'author_username param' do + it 'returns stage events associated with the given author' do + params[:author_username] = project.creator.username + + expect(issue_ids).to eq([issue_1.id]) + end + + it 'returns empty result when unknown author is given' do + params[:author_username] = 'no one' + + expect(issue_ids).to be_empty + end + end + + describe 'milestone_title param' do + it 'returns stage events associated with the milestone' do + params[:milestone_title] = milestone.title + + expect(issue_ids).to eq([issue_2.id]) + end + + it 'returns empty result when unknown milestone is given' do + params[:milestone_title] = 'unknown milestone' + + expect(issue_ids).to be_empty + end + end + + describe 'label_name param' do + it 'returns stage events associated with multiple labels' do + params[:label_name] = [label_1.name, label_2.name] + + expect(issue_ids).to eq([issue_1.id]) + end + + it 'does not include records with partial label match' do + params[:label_name] = [label_1.name, 'other label'] + + expect(issue_ids).to be_empty + end + end + + describe 'assignee_username param' do + it 'returns stage events associated assignee' do + params[:assignee_username] = [user_1.username] + + expect(issue_ids).to eq([issue_2.id]) + end + end + + describe 'timestamp filtering' do + before do + params[:from] = 1.year.ago + params[:to] = 6.months.ago + end + + it 'filters by the end event time range' do + expect(issue_ids).to eq([issue_1.id]) + end + + context 'when in_progress items are requested' do + before do + params[:end_event_filter] = :in_progress + end + + it 'filters by the start event time range' do + expect(issue_ids).to eq([issue_3.id]) + end + end + end +end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/aggregated/records_fetcher_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/aggregated/records_fetcher_spec.rb new file mode 100644 index 00000000000..045cdb129cb --- /dev/null +++ b/spec/lib/gitlab/analytics/cycle_analytics/aggregated/records_fetcher_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Analytics::CycleAnalytics::Aggregated::RecordsFetcher do + let_it_be(:project) { create(:project) } + let_it_be(:issue_1) { create(:issue, project: project) } + let_it_be(:issue_2) { create(:issue, project: project) } + let_it_be(:issue_3) { create(:issue, project: project) } + + let_it_be(:stage_event_1) { create(:cycle_analytics_issue_stage_event, issue_id: issue_1.id, start_event_timestamp: 2.years.ago, end_event_timestamp: 1.year.ago) } # duration: 1 year + let_it_be(:stage_event_2) { create(:cycle_analytics_issue_stage_event, issue_id: issue_2.id, start_event_timestamp: 5.years.ago, end_event_timestamp: 2.years.ago) } # duration: 3 years + let_it_be(:stage_event_3) { create(:cycle_analytics_issue_stage_event, issue_id: issue_3.id, start_event_timestamp: 6.years.ago, end_event_timestamp: 3.months.ago) } # duration: 5+ years + + let_it_be(:stage) { create(:cycle_analytics_project_stage, start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production, project: project) } + + let(:params) { {} } + + subject(:records_fetcher) do + described_class.new(stage: stage, query: Analytics::CycleAnalytics::IssueStageEvent.all, params: params) + end + + shared_examples 'match returned records' do + it 'returns issues in the correct order' do + returned_iids = records_fetcher.serialized_records.pluck(:iid).map(&:to_i) + + expect(returned_iids).to eq(expected_issue_ids) + end + end + + describe '#serialized_records' do + describe 'sorting' do + context 'when sorting by end event DESC' do + let(:expected_issue_ids) { [issue_3.iid, issue_1.iid, issue_2.iid] } + + before do + params[:sort] = :end_event + params[:direction] = :desc + end + + it_behaves_like 'match returned records' + end + + context 'when sorting by end event ASC' do + let(:expected_issue_ids) { [issue_2.iid, issue_1.iid, issue_3.iid] } + + before do + params[:sort] = :end_event + params[:direction] = :asc + end + + it_behaves_like 'match returned records' + end + + context 'when sorting by duration DESC' do + let(:expected_issue_ids) { [issue_3.iid, issue_2.iid, issue_1.iid] } + + before do + params[:sort] = :duration + params[:direction] = :desc + end + + it_behaves_like 'match returned records' + end + + context 'when sorting by duration ASC' do + let(:expected_issue_ids) { [issue_1.iid, issue_2.iid, issue_3.iid] } + + before do + params[:sort] = :duration + params[:direction] = :asc + end + + it_behaves_like 'match returned records' + end + end + + describe 'pagination' do + let(:expected_issue_ids) { [issue_3.iid] } + + before do + params[:sort] = :duration + params[:direction] = :asc + params[:page] = 2 + + stub_const('Gitlab::Analytics::CycleAnalytics::Aggregated::RecordsFetcher::MAX_RECORDS', 2) + end + + it_behaves_like 'match returned records' + end + + context 'when passing a block to serialized_records method' do + before do + params[:sort] = :duration + params[:direction] = :asc + end + + it 'yields the underlying stage event scope' do + stage_event_records = [] + + records_fetcher.serialized_records do |scope| + stage_event_records.concat(scope.to_a) + end + + expect(stage_event_records.map(&:issue_id)).to eq([issue_1.id, issue_2.id, issue_3.id]) + end + end + + context 'when the issue record no longer exists' do + it 'skips non-existing issue records' do + create(:cycle_analytics_issue_stage_event, { + issue_id: 0, # non-existing id + start_event_timestamp: 5.months.ago, + end_event_timestamp: 3.months.ago + }) + + stage_event_count = nil + + records_fetcher.serialized_records do |scope| + stage_event_count = scope.to_a.size + end + + issue_count = records_fetcher.serialized_records.to_a.size + + expect(stage_event_count).to eq(4) + expect(issue_count).to eq(3) + end + end + end +end diff --git a/spec/lib/gitlab/application_rate_limiter_spec.rb b/spec/lib/gitlab/application_rate_limiter_spec.rb index 0fb99688d27..c74bcf8d678 100644 --- a/spec/lib/gitlab/application_rate_limiter_spec.rb +++ b/spec/lib/gitlab/application_rate_limiter_spec.rb @@ -3,76 +3,108 @@ require 'spec_helper' RSpec.describe Gitlab::ApplicationRateLimiter do - let(:redis) { double('redis') } - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:rate_limits) do - { - test_action: { - threshold: 1, - interval: 2.minutes + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + + subject { described_class } + + describe '.throttled?', :clean_gitlab_redis_rate_limiting do + let(:rate_limits) do + { + test_action: { + threshold: 1, + interval: 2.minutes + }, + another_action: { + threshold: 2, + interval: 3.minutes + } } - } - end + end - let(:key) { rate_limits.keys[0] } + before do + allow(described_class).to receive(:rate_limits).and_return(rate_limits) + end - subject { described_class } + context 'when the key is invalid' do + context 'is provided as a Symbol' do + context 'but is not defined in the rate_limits Hash' do + it 'raises an InvalidKeyError exception' do + key = :key_not_in_rate_limits_hash - before do - allow(Gitlab::Redis::RateLimiting).to receive(:with).and_yield(redis) - allow(described_class).to receive(:rate_limits).and_return(rate_limits) - end + expect { subject.throttled?(key) }.to raise_error(Gitlab::ApplicationRateLimiter::InvalidKeyError) + end + end + end - shared_examples 'action rate limiter' do - it 'increases the throttle count and sets the expiration time' do - expect(redis).to receive(:incr).with(cache_key).and_return(1) - expect(redis).to receive(:expire).with(cache_key, 120) + context 'is provided as a String' do + context 'and is a String representation of an existing key in rate_limits Hash' do + it 'raises an InvalidKeyError exception' do + key = rate_limits.keys[0].to_s - expect(subject.throttled?(key, scope: scope)).to be_falsy - end + expect { subject.throttled?(key) }.to raise_error(Gitlab::ApplicationRateLimiter::InvalidKeyError) + end + end - it 'returns true if the key is throttled' do - expect(redis).to receive(:incr).with(cache_key).and_return(2) - expect(redis).not_to receive(:expire) + context 'but is not defined in any form in the rate_limits Hash' do + it 'raises an InvalidKeyError exception' do + key = 'key_not_in_rate_limits_hash' - expect(subject.throttled?(key, scope: scope)).to be_truthy + expect { subject.throttled?(key) }.to raise_error(Gitlab::ApplicationRateLimiter::InvalidKeyError) + end + end + end end - context 'when throttling is disabled' do - it 'returns false and does not set expiration time' do - expect(redis).not_to receive(:incr) - expect(redis).not_to receive(:expire) + shared_examples 'throttles based on key and scope' do + let(:start_time) { Time.current.beginning_of_hour } - expect(subject.throttled?(key, scope: scope, threshold: 0)).to be_falsy + it 'returns true when threshold is exceeded' do + travel_to(start_time) do + expect(subject.throttled?(:test_action, scope: scope)).to eq(false) + end + + travel_to(start_time + 1.minute) do + expect(subject.throttled?(:test_action, scope: scope)).to eq(true) + + # Assert that it does not affect other actions or scope + expect(subject.throttled?(:another_action, scope: scope)).to eq(false) + expect(subject.throttled?(:test_action, scope: [user])).to eq(false) + end end - end - end - context 'when the key is an array of only ActiveRecord models' do - let(:scope) { [user, project] } + it 'returns false when interval has elapsed' do + travel_to(start_time) do + expect(subject.throttled?(:test_action, scope: scope)).to eq(false) - let(:cache_key) do - "application_rate_limiter:test_action:user:#{user.id}:project:#{project.id}" - end + # another_action has a threshold of 3 so we simulate 2 requests + expect(subject.throttled?(:another_action, scope: scope)).to eq(false) + expect(subject.throttled?(:another_action, scope: scope)).to eq(false) + end - it_behaves_like 'action rate limiter' - end + travel_to(start_time + 2.minutes) do + expect(subject.throttled?(:test_action, scope: scope)).to eq(false) - context 'when they key a combination of ActiveRecord models and strings' do - let(:project) { create(:project, :public, :repository) } - let(:commit) { project.repository.commit } - let(:path) { 'app/controllers/groups_controller.rb' } - let(:scope) { [project, commit, path] } + # Assert that another_action has its own interval that hasn't elapsed + expect(subject.throttled?(:another_action, scope: scope)).to eq(true) + end + end + end + + context 'when using ActiveRecord models as scope' do + let(:scope) { [user, project] } - let(:cache_key) do - "application_rate_limiter:test_action:project:#{project.id}:commit:#{commit.sha}:#{path}" + it_behaves_like 'throttles based on key and scope' end - it_behaves_like 'action rate limiter' + context 'when using ActiveRecord models and strings as scope' do + let(:scope) { [project, 'app/controllers/groups_controller.rb'] } + + it_behaves_like 'throttles based on key and scope' + end end - describe '#log_request' do + describe '.log_request' do let(:file_path) { 'master/README.md' } let(:type) { :raw_blob_request_limit } let(:fullpath) { "/#{project.full_path}/raw/#{file_path}" } @@ -102,7 +134,7 @@ RSpec.describe Gitlab::ApplicationRateLimiter do end context 'with a current_user' do - let(:current_user) { create(:user) } + let(:current_user) { user } let(:attributes) do base_attributes.merge({ diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index f3799c58fed..ac29bb22865 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -11,27 +11,13 @@ module Gitlab allow_any_instance_of(ApplicationSetting).to receive(:current).and_return(::ApplicationSetting.create_from_defaults) end - context "without project" do - let(:input) { 'ascii' } - let(:context) { {} } - let(:html) { 'H2O' } - - it "converts the input using Asciidoctor and default options" do - expected_asciidoc_opts = { - safe: :secure, - backend: :gitlab_html5, - attributes: described_class::DEFAULT_ADOC_ATTRS.merge({ "kroki-server-url" => nil }), - extensions: be_a(Proc) - } - - expect(Asciidoctor).to receive(:convert) - .with(input, expected_asciidoc_opts).and_return(html) - - expect(render(input, context)).to eq(html) - end + shared_examples_for 'renders correct asciidoc' do + context "without project" do + let(:input) { 'ascii' } + let(:context) { {} } + let(:html) { 'H2O' } - context "with asciidoc_opts" do - it "merges the options with default ones" do + it "converts the input using Asciidoctor and default options" do expected_asciidoc_opts = { safe: :secure, backend: :gitlab_html5, @@ -42,796 +28,839 @@ module Gitlab expect(Asciidoctor).to receive(:convert) .with(input, expected_asciidoc_opts).and_return(html) - render(input, context) + expect(render(input, context)).to eq(html) end - end - context "with requested path" do - input = <<~ADOC - Document name: {docname}. - ADOC - - it "ignores {docname} when not available" do - expect(render(input, {})).to include(input.strip) - end - - [ - ['/', '', 'root'], - ['README', 'README', 'just a filename'], - ['doc/api/', '', 'a directory'], - ['doc/api/README.adoc', 'README', 'a complete path'] - ].each do |path, basename, desc| - it "sets {docname} for #{desc}" do - expect(render(input, { requested_path: path })).to include(": #{basename}.") - end - end - end + context "with asciidoc_opts" do + it "merges the options with default ones" do + expected_asciidoc_opts = { + safe: :secure, + backend: :gitlab_html5, + attributes: described_class::DEFAULT_ADOC_ATTRS.merge({ "kroki-server-url" => nil }), + extensions: be_a(Proc) + } - context "XSS" do - items = { - 'link with extra attribute' => { - input: 'link:mylink"onmouseover="alert(1)[Click Here]', - output: "" - }, - 'link with unsafe scheme' => { - input: 'link:data://danger[Click Here]', - output: "" - }, - 'image with onerror' => { - input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]', - output: "
        \n

        Alt text\" onerror=\"alert(7)

        \n
        " - }, - 'fenced code with inline script' => { - input: '```mypre">', - output: "
        \n
        \n
        \">
        \n
        \n
        " - } - } + expect(Asciidoctor).to receive(:convert) + .with(input, expected_asciidoc_opts).and_return(html) - items.each do |name, data| - it "does not convert dangerous #{name} into HTML" do - expect(render(data[:input], context)).to include(data[:output]) + render(input, context) end end - it 'does not allow locked attributes to be overridden' do + context "with requested path" do input = <<~ADOC - {counter:max-include-depth:1234} - <|-- {max-include-depth} + Document name: {docname}. ADOC - expect(render(input, {})).not_to include('1234') - end - end + it "ignores {docname} when not available" do + expect(render(input, {})).to include(input.strip) + end - context "images" do - it "does lazy load and link image" do - input = 'image:https://localhost.com/image.png[]' - output = "
        \n

        \"image\"

        \n
        " - expect(render(input, context)).to include(output) + [ + ['/', '', 'root'], + ['README', 'README', 'just a filename'], + ['doc/api/', '', 'a directory'], + ['doc/api/README.adoc', 'README', 'a complete path'] + ].each do |path, basename, desc| + it "sets {docname} for #{desc}" do + expect(render(input, { requested_path: path })).to include(": #{basename}.") + end + end end - it "does not automatically link image if link is explicitly defined" do - input = 'image:https://localhost.com/image.png[link=https://gitlab.com]' - output = "
        \n

        \"image\"

        \n
        " - expect(render(input, context)).to include(output) - end - end + context "XSS" do + items = { + 'link with extra attribute' => { + input: 'link:mylink"onmouseover="alert(1)[Click Here]', + output: "" + }, + 'link with unsafe scheme' => { + input: 'link:data://danger[Click Here]', + output: "" + }, + 'image with onerror' => { + input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]', + output: "
        \n

        Alt text\" onerror=\"alert(7)

        \n
        " + } + } - context 'with admonition' do - it 'preserves classes' do - input = <<~ADOC - NOTE: An admonition paragraph, like this note, grabs the reader’s attention. - ADOC + items.each do |name, data| + it "does not convert dangerous #{name} into HTML" do + expect(render(data[:input], context)).to include(data[:output]) + end + end - output = <<~HTML -
        - - - - - -
        - - - An admonition paragraph, like this note, grabs the reader’s attention. -
        -
        - HTML - - expect(render(input, context)).to include(output.strip) - end - end + # `stub_feature_flags method` runs AFTER declaration of `items` above. + # So the spec in its current implementation won't pass. + # Move this test back to the items hash when removing `use_cmark_renderer` feature flag. + it "does not convert dangerous fenced code with inline script into HTML" do + input = '```mypre">' + output = + if Feature.enabled?(:use_cmark_renderer) + "
        \n
        \n
        \n
        \n
        " + else + "
        \n
        \n
        \">
        \n
        \n
        " + end - context 'with passthrough' do - it 'removes non heading ids' do - input = <<~ADOC - ++++ -

        Title

        - ++++ - ADOC + expect(render(input, context)).to include(output) + end - output = <<~HTML -

        Title

        - HTML + it 'does not allow locked attributes to be overridden' do + input = <<~ADOC + {counter:max-include-depth:1234} + <|-- {max-include-depth} + ADOC - expect(render(input, context)).to include(output.strip) + expect(render(input, {})).not_to include('1234') + end end - it 'removes non footnote def ids' do - input = <<~ADOC - ++++ -
        Footnote definition
        - ++++ - ADOC - - output = <<~HTML -
        Footnote definition
        - HTML + context "images" do + it "does lazy load and link image" do + input = 'image:https://localhost.com/image.png[]' + output = "
        \n

        \"image\"

        \n
        " + expect(render(input, context)).to include(output) + end - expect(render(input, context)).to include(output.strip) + it "does not automatically link image if link is explicitly defined" do + input = 'image:https://localhost.com/image.png[link=https://gitlab.com]' + output = "
        \n

        \"image\"

        \n
        " + expect(render(input, context)).to include(output) + end end - it 'removes non footnote ref ids' do - input = <<~ADOC - ++++ - Footnote reference - ++++ - ADOC - - output = <<~HTML - Footnote reference - HTML + context 'with admonition' do + it 'preserves classes' do + input = <<~ADOC + NOTE: An admonition paragraph, like this note, grabs the reader’s attention. + ADOC - expect(render(input, context)).to include(output.strip) + output = <<~HTML +
        + + + + + +
        + + + An admonition paragraph, like this note, grabs the reader’s attention. +
        +
        + HTML + + expect(render(input, context)).to include(output.strip) + end end - end - context 'with footnotes' do - it 'preserves ids and links' do - input = <<~ADOC - This paragraph has a footnote.footnote:[This is the text of the footnote.] - ADOC - - output = <<~HTML -
        -

        This paragraph has a footnote.[1]

        -
        -
        -
        -
        - 1. This is the text of the footnote. -
        -
        - HTML - - expect(render(input, context)).to include(output.strip) - end - end + context 'with passthrough' do + it 'removes non heading ids' do + input = <<~ADOC + ++++ +

        Title

        + ++++ + ADOC - context 'with section anchors' do - it 'preserves ids and links' do - input = <<~ADOC - = Title + output = <<~HTML +

        Title

        + HTML - == First section + expect(render(input, context)).to include(output.strip) + end - This is the first section. + it 'removes non footnote def ids' do + input = <<~ADOC + ++++ +
        Footnote definition
        + ++++ + ADOC - == Second section + output = <<~HTML +
        Footnote definition
        + HTML - This is the second section. + expect(render(input, context)).to include(output.strip) + end - == Thunder ⚡ ! + it 'removes non footnote ref ids' do + input = <<~ADOC + ++++ + Footnote reference + ++++ + ADOC - This is the third section. - ADOC + output = <<~HTML + Footnote reference + HTML - output = <<~HTML -

        Title

        -
        -

        - First section

        -
        -
        -

        This is the first section.

        -
        -
        -
        -
        -

        - Second section

        -
        -
        -

        This is the second section.

        -
        -
        -
        -
        -

        - Thunder ⚡ !

        -
        -
        -

        This is the third section.

        -
        -
        -
        - HTML - - expect(render(input, context)).to include(output.strip) + expect(render(input, context)).to include(output.strip) + end end - end - - context 'with xrefs' do - it 'preserves ids' do - input = <<~ADOC - Learn how to xref:cross-references[use cross references]. - [[cross-references]]A link to another location within an AsciiDoc document or between AsciiDoc documents is called a cross reference (also referred to as an xref). - ADOC - - output = <<~HTML -
        -

        Learn how to use cross references.

        -
        -
        -

        A link to another location within an AsciiDoc document or between AsciiDoc documents is called a cross reference (also referred to as an xref).

        -
        - HTML + context 'with footnotes' do + it 'preserves ids and links' do + input = <<~ADOC + This paragraph has a footnote.footnote:[This is the text of the footnote.] + ADOC - expect(render(input, context)).to include(output.strip) + output = <<~HTML +
        +

        This paragraph has a footnote.[1]

        +
        +
        +
        +
        + 1. This is the text of the footnote. +
        +
        + HTML + + expect(render(input, context)).to include(output.strip) + end end - end - context 'with checklist' do - it 'preserves classes' do - input = <<~ADOC - * [x] checked - * [ ] not checked - ADOC + context 'with section anchors' do + it 'preserves ids and links' do + input = <<~ADOC + = Title + + == First section + + This is the first section. + + == Second section + + This is the second section. + + == Thunder ⚡ ! + + This is the third section. + ADOC - output = <<~HTML -
        -
          -
        • -

          checked

          -
        • -
        • -

          not checked

          -
        • -
        -
        - HTML - - expect(render(input, context)).to include(output.strip) + output = <<~HTML +

        Title

        +
        +

        + First section

        +
        +
        +

        This is the first section.

        +
        +
        +
        +
        +

        + Second section

        +
        +
        +

        This is the second section.

        +
        +
        +
        +
        +

        + Thunder ⚡ !

        +
        +
        +

        This is the third section.

        +
        +
        +
        + HTML + + expect(render(input, context)).to include(output.strip) + end end - end - - context 'with marks' do - it 'preserves classes' do - input = <<~ADOC - Werewolves are allergic to #cassia cinnamon#. - - Did the werewolves read the [.small]#small print#? - Where did all the [.underline.small]#cores# run off to? + context 'with xrefs' do + it 'preserves ids' do + input = <<~ADOC + Learn how to xref:cross-references[use cross references]. + + [[cross-references]]A link to another location within an AsciiDoc document or between AsciiDoc documents is called a cross reference (also referred to as an xref). + ADOC - We need [.line-through]#ten# make that twenty VMs. + output = <<~HTML +
        +

        Learn how to use cross references.

        +
        +
        +

        A link to another location within an AsciiDoc document or between AsciiDoc documents is called a cross reference (also referred to as an xref).

        +
        + HTML - [.big]##O##nce upon an infinite loop. - ADOC - - output = <<~HTML -
        -

        Werewolves are allergic to cassia cinnamon.

        -
        -
        -

        Did the werewolves read the small print?

        -
        -
        -

        Where did all the cores run off to?

        -
        -
        -

        We need ten make that twenty VMs.

        -
        -
        -

        Once upon an infinite loop.

        -
        - HTML - - expect(render(input, context)).to include(output.strip) + expect(render(input, context)).to include(output.strip) + end end - end - context 'with fenced block' do - it 'highlights syntax' do - input = <<~ADOC - ```js - console.log('hello world') - ``` - ADOC - - output = <<~HTML -
        -
        -
        console.log('hello world')
        -
        -
        - HTML + context 'with checklist' do + it 'preserves classes' do + input = <<~ADOC + * [x] checked + * [ ] not checked + ADOC - expect(render(input, context)).to include(output.strip) + output = <<~HTML +
        +
          +
        • +

          checked

          +
        • +
        • +

          not checked

          +
        • +
        +
        + HTML + + expect(render(input, context)).to include(output.strip) + end end - end - context 'with listing block' do - it 'highlights syntax' do - input = <<~ADOC - [source,c++] - .class.cpp - ---- - #include - - for (int i = 0; i < 5; i++) { - std::cout<<"*"< -
        class.cpp
        -
        -
        #include <stdio.h>
        -            
        -            for (int i = 0; i < 5; i++) {
        -              std::cout<<"*"<<std::endl;
        -            }
        -
        -
  • - HTML - - expect(render(input, context)).to include(output.strip) + output = <<~HTML +
    +

    Werewolves are allergic to cassia cinnamon.

    +
    +
    +

    Did the werewolves read the small print?

    +
    +
    +

    Where did all the cores run off to?

    +
    +
    +

    We need ten make that twenty VMs.

    +
    +
    +

    Once upon an infinite loop.

    +
    + HTML + + expect(render(input, context)).to include(output.strip) + end end - end - context 'with stem block' do - it 'does not apply syntax highlighting' do - input = <<~ADOC - [stem] - ++++ - \sqrt{4} = 2 - ++++ - ADOC + context 'with fenced block' do + it 'highlights syntax' do + input = <<~ADOC + ```js + console.log('hello world') + ``` + ADOC - output = "
    \n
    \n\\$ qrt{4} = 2\\$\n
    \n
    " + output = <<~HTML +
    +
    +
    console.log('hello world')
    +
    +
    + HTML - expect(render(input, context)).to include(output) + expect(render(input, context)).to include(output.strip) + end end - end - context 'external links' do - it 'adds the `rel` attribute to the link' do - output = render('link:https://google.com[Google]', context) + context 'with listing block' do + it 'highlights syntax' do + input = <<~ADOC + [source,c++] + .class.cpp + ---- + #include + + for (int i = 0; i < 5; i++) { + std::cout<<"*"< +
    class.cpp
    +
    +
    #include <stdio.h>
    +              
    +              for (int i = 0; i < 5; i++) {
    +                std::cout<<"*"<<std::endl;
    +              }
    +
    +
    + HTML + + expect(render(input, context)).to include(output.strip) + end end - end - context 'LaTex code' do - it 'adds class js-render-math to the output' do - input = <<~MD - :stem: latexmath - - [stem] - ++++ - \sqrt{4} = 2 - ++++ - - another part - - [latexmath] - ++++ - \beta_x \gamma - ++++ + context 'with stem block' do + it 'does not apply syntax highlighting' do + input = <<~ADOC + [stem] + ++++ + \sqrt{4} = 2 + ++++ + ADOC - stem:[2+2] is 4 - MD + output = "
    \n
    \n\\$ qrt{4} = 2\\$\n
    \n
    " - expect(render(input, context)).to include('
    eta_x gamma
    ') - expect(render(input, context)).to include('

    2+2 is 4

    ') + expect(render(input, context)).to include(output) + end end - end - context 'outfilesuffix' do - it 'defaults to adoc' do - output = render("Inter-document reference <>", context) + context 'external links' do + it 'adds the `rel` attribute to the link' do + output = render('link:https://google.com[Google]', context) - expect(output).to include("a href=\"README.adoc\"") + expect(output).to include('rel="nofollow noreferrer noopener"') + end end - end - context 'with mermaid diagrams' do - it 'adds class js-render-mermaid to the output' do - input = <<~MD - [mermaid] - .... - graph LR - A[Square Rect] -- Link text --> B((Circle)) - A --> C(Round Rect) - B --> D{Rhombus} - C --> D - .... - MD - - output = <<~HTML -
    graph LR
    -                A[Square Rect] -- Link text --> B((Circle))
    -                A --> C(Round Rect)
    -                B --> D{Rhombus}
    -                C --> D
    - HTML - - expect(render(input, context)).to include(output.strip) + context 'LaTex code' do + it 'adds class js-render-math to the output' do + input = <<~MD + :stem: latexmath + + [stem] + ++++ + \sqrt{4} = 2 + ++++ + + another part + + [latexmath] + ++++ + \beta_x \gamma + ++++ + + stem:[2+2] is 4 + MD + + expect(render(input, context)).to include('
    eta_x gamma
    ') + expect(render(input, context)).to include('

    2+2 is 4

    ') + end end - it 'applies subs in diagram block' do - input = <<~MD - :class-name: AveryLongClass + context 'outfilesuffix' do + it 'defaults to adoc' do + output = render("Inter-document reference <>", context) - [mermaid,subs=+attributes] - .... - classDiagram - Class01 <|-- {class-name} : Cool - .... - MD + expect(output).to include("a href=\"README.adoc\"") + end + end - output = <<~HTML -
    classDiagram
    -            Class01 <|-- AveryLongClass : Cool
    - HTML + context 'with mermaid diagrams' do + it 'adds class js-render-mermaid to the output' do + input = <<~MD + [mermaid] + .... + graph LR + A[Square Rect] -- Link text --> B((Circle)) + A --> C(Round Rect) + B --> D{Rhombus} + C --> D + .... + MD + + output = <<~HTML +
    graph LR
    +                  A[Square Rect] -- Link text --> B((Circle))
    +                  A --> C(Round Rect)
    +                  B --> D{Rhombus}
    +                  C --> D
    + HTML + + expect(render(input, context)).to include(output.strip) + end - expect(render(input, context)).to include(output.strip) + it 'applies subs in diagram block' do + input = <<~MD + :class-name: AveryLongClass + + [mermaid,subs=+attributes] + .... + classDiagram + Class01 <|-- {class-name} : Cool + .... + MD + + output = <<~HTML +
    classDiagram
    +              Class01 <|-- AveryLongClass : Cool
    + HTML + + expect(render(input, context)).to include(output.strip) + end end - end - context 'with Kroki enabled' do - before do - allow_any_instance_of(ApplicationSetting).to receive(:kroki_enabled).and_return(true) - allow_any_instance_of(ApplicationSetting).to receive(:kroki_url).and_return('https://kroki.io') - end + context 'with Kroki enabled' do + before do + allow_any_instance_of(ApplicationSetting).to receive(:kroki_enabled).and_return(true) + allow_any_instance_of(ApplicationSetting).to receive(:kroki_url).and_return('https://kroki.io') + end - it 'converts a graphviz diagram to image' do - input = <<~ADOC - [graphviz] - .... - digraph G { - Hello->World - } - .... - ADOC + it 'converts a graphviz diagram to image' do + input = <<~ADOC + [graphviz] + .... + digraph G { + Hello->World + } + .... + ADOC - output = <<~HTML -
    -
    - Diagram -
    -
    - HTML + output = <<~HTML +
    +
    + Diagram +
    +
    + HTML - expect(render(input, context)).to include(output.strip) - end + expect(render(input, context)).to include(output.strip) + end - it 'does not convert a blockdiag diagram to image' do - input = <<~ADOC - [blockdiag] - .... - blockdiag { - Kroki -> generates -> "Block diagrams"; - Kroki -> is -> "very easy!"; - - Kroki [color = "greenyellow"]; - "Block diagrams" [color = "pink"]; - "very easy!" [color = "orange"]; - } - .... - ADOC + it 'does not convert a blockdiag diagram to image' do + input = <<~ADOC + [blockdiag] + .... + blockdiag { + Kroki -> generates -> "Block diagrams"; + Kroki -> is -> "very easy!"; + + Kroki [color = "greenyellow"]; + "Block diagrams" [color = "pink"]; + "very easy!" [color = "orange"]; + } + .... + ADOC - output = <<~HTML -
    -
    -
    blockdiag {
    -              Kroki -> generates -> "Block diagrams";
    -              Kroki -> is -> "very easy!";
    -
    -              Kroki [color = "greenyellow"];
    -              "Block diagrams" [color = "pink"];
    -              "very easy!" [color = "orange"];
    -            }
    -
    -
    - HTML - - expect(render(input, context)).to include(output.strip) - end + output = <<~HTML +
    +
    +
    blockdiag {
    +                Kroki -> generates -> "Block diagrams";
    +                Kroki -> is -> "very easy!";
    +  
    +                Kroki [color = "greenyellow"];
    +                "Block diagrams" [color = "pink"];
    +                "very easy!" [color = "orange"];
    +              }
    +
    +
    + HTML + + expect(render(input, context)).to include(output.strip) + end - it 'does not allow kroki-plantuml-include to be overridden' do - input = <<~ADOC - [plantuml, test="{counter:kroki-plantuml-include:/etc/passwd}", format="png"] - .... - class BlockProcessor + it 'does not allow kroki-plantuml-include to be overridden' do + input = <<~ADOC + [plantuml, test="{counter:kroki-plantuml-include:/etc/passwd}", format="png"] + .... + class BlockProcessor + + BlockProcessor <|-- {counter:kroki-plantuml-include} + .... + ADOC - BlockProcessor <|-- {counter:kroki-plantuml-include} - .... - ADOC + output = <<~HTML +
    +
    + \"Diagram\" +
    +
    + HTML + + expect(render(input, {})).to include(output.strip) + end - output = <<~HTML -
    -
    - \"Diagram\" -
    -
    - HTML + it 'does not allow kroki-server-url to be overridden' do + input = <<~ADOC + [plantuml, test="{counter:kroki-server-url:evilsite}", format="png"] + .... + class BlockProcessor + + BlockProcessor + .... + ADOC - expect(render(input, {})).to include(output.strip) + expect(render(input, {})).not_to include('evilsite') + end end - it 'does not allow kroki-server-url to be overridden' do - input = <<~ADOC - [plantuml, test="{counter:kroki-server-url:evilsite}", format="png"] - .... - class BlockProcessor + context 'with Kroki and BlockDiag (additional format) enabled' do + before do + allow_any_instance_of(ApplicationSetting).to receive(:kroki_enabled).and_return(true) + allow_any_instance_of(ApplicationSetting).to receive(:kroki_url).and_return('https://kroki.io') + allow_any_instance_of(ApplicationSetting).to receive(:kroki_formats_blockdiag).and_return(true) + end - BlockProcessor - .... - ADOC + it 'converts a blockdiag diagram to image' do + input = <<~ADOC + [blockdiag] + .... + blockdiag { + Kroki -> generates -> "Block diagrams"; + Kroki -> is -> "very easy!"; + + Kroki [color = "greenyellow"]; + "Block diagrams" [color = "pink"]; + "very easy!" [color = "orange"]; + } + .... + ADOC - expect(render(input, {})).not_to include('evilsite') + output = <<~HTML +
    +
    + Diagram +
    +
    + HTML + + expect(render(input, context)).to include(output.strip) + end end end - context 'with Kroki and BlockDiag (additional format) enabled' do - before do - allow_any_instance_of(ApplicationSetting).to receive(:kroki_enabled).and_return(true) - allow_any_instance_of(ApplicationSetting).to receive(:kroki_url).and_return('https://kroki.io') - allow_any_instance_of(ApplicationSetting).to receive(:kroki_formats_blockdiag).and_return(true) + context 'with project' do + let(:context) do + { + commit: commit, + project: project, + ref: ref, + requested_path: requested_path + } end - it 'converts a blockdiag diagram to image' do - input = <<~ADOC - [blockdiag] - .... - blockdiag { - Kroki -> generates -> "Block diagrams"; - Kroki -> is -> "very easy!"; - - Kroki [color = "greenyellow"]; - "Block diagrams" [color = "pink"]; - "very easy!" [color = "orange"]; - } - .... - ADOC - - output = <<~HTML -
    -
    - Diagram -
    -
    - HTML + let(:commit) { project.commit(ref) } + let(:project) { create(:project, :repository) } + let(:ref) { 'asciidoc' } + let(:requested_path) { '/' } - expect(render(input, context)).to include(output.strip) - end - end - end + context 'include directive' do + subject(:output) { render(input, context) } - context 'with project' do - let(:context) do - { - commit: commit, - project: project, - ref: ref, - requested_path: requested_path - } - end + let(:input) { "Include this:\n\ninclude::#{include_path}[]" } - let(:commit) { project.commit(ref) } - let(:project) { create(:project, :repository) } - let(:ref) { 'asciidoc' } - let(:requested_path) { '/' } - - context 'include directive' do - subject(:output) { render(input, context) } + before do + current_file = requested_path + current_file += 'README.adoc' if requested_path.end_with? '/' - let(:input) { "Include this:\n\ninclude::#{include_path}[]" } + create_file(current_file, "= AsciiDoc\n") + end - before do - current_file = requested_path - current_file += 'README.adoc' if requested_path.end_with? '/' + def many_includes(target) + Array.new(10, "include::#{target}[]").join("\n") + end - create_file(current_file, "= AsciiDoc\n") - end + context 'cyclic imports' do + before do + create_file('doc/api/a.adoc', many_includes('b.adoc')) + create_file('doc/api/b.adoc', many_includes('a.adoc')) + end - def many_includes(target) - Array.new(10, "include::#{target}[]").join("\n") - end + let(:include_path) { 'a.adoc' } + let(:requested_path) { 'doc/api/README.md' } - context 'cyclic imports' do - before do - create_file('doc/api/a.adoc', many_includes('b.adoc')) - create_file('doc/api/b.adoc', many_includes('a.adoc')) + it 'completes successfully' do + is_expected.to include('

    Include this:

    ') + end end - let(:include_path) { 'a.adoc' } - let(:requested_path) { 'doc/api/README.md' } + context 'with path to non-existing file' do + let(:include_path) { 'not-exists.adoc' } - it 'completes successfully' do - is_expected.to include('

    Include this:

    ') + it 'renders Unresolved directive placeholder' do + is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]") + end end - end - context 'with path to non-existing file' do - let(:include_path) { 'not-exists.adoc' } + shared_examples :invalid_include do + let(:include_path) { 'dk.png' } - it 'renders Unresolved directive placeholder' do - is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]") - end - end + before do + allow(project.repository).to receive(:blob_at).and_return(blob) + end - shared_examples :invalid_include do - let(:include_path) { 'dk.png' } + it 'does not read the blob' do + expect(blob).not_to receive(:data) + end - before do - allow(project.repository).to receive(:blob_at).and_return(blob) + it 'renders Unresolved directive placeholder' do + is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]") + end end - it 'does not read the blob' do - expect(blob).not_to receive(:data) - end + context 'with path to a binary file' do + let(:blob) { fake_blob(path: 'dk.png', binary: true) } - it 'renders Unresolved directive placeholder' do - is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]") + include_examples :invalid_include end - end - - context 'with path to a binary file' do - let(:blob) { fake_blob(path: 'dk.png', binary: true) } - include_examples :invalid_include - end + context 'with path to file in external storage' do + let(:blob) { fake_blob(path: 'dk.png', lfs: true) } - context 'with path to file in external storage' do - let(:blob) { fake_blob(path: 'dk.png', lfs: true) } + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + project.update_attribute(:lfs_enabled, true) + end - before do - allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) - project.update_attribute(:lfs_enabled, true) + include_examples :invalid_include end - include_examples :invalid_include - end + context 'with path to a textual file' do + let(:include_path) { 'sample.adoc' } - context 'with path to a textual file' do - let(:include_path) { 'sample.adoc' } + before do + create_file(file_path, "Content from #{include_path}") + end - before do - create_file(file_path, "Content from #{include_path}") - end - - shared_examples :valid_include do - [ - ['/doc/sample.adoc', 'doc/sample.adoc', 'absolute path'], - ['sample.adoc', 'doc/api/sample.adoc', 'relative path'], - ['./sample.adoc', 'doc/api/sample.adoc', 'relative path with leading ./'], - ['../sample.adoc', 'doc/sample.adoc', 'relative path to a file up one directory'], - ['../../sample.adoc', 'sample.adoc', 'relative path for a file up multiple directories'] - ].each do |include_path_, file_path_, desc| - context "the file is specified by #{desc}" do - let(:include_path) { include_path_ } - let(:file_path) { file_path_ } - - it 'includes content of the file' do - is_expected.to include('

    Include this:

    ') - is_expected.to include("

    Content from #{include_path}

    ") + shared_examples :valid_include do + [ + ['/doc/sample.adoc', 'doc/sample.adoc', 'absolute path'], + ['sample.adoc', 'doc/api/sample.adoc', 'relative path'], + ['./sample.adoc', 'doc/api/sample.adoc', 'relative path with leading ./'], + ['../sample.adoc', 'doc/sample.adoc', 'relative path to a file up one directory'], + ['../../sample.adoc', 'sample.adoc', 'relative path for a file up multiple directories'] + ].each do |include_path_, file_path_, desc| + context "the file is specified by #{desc}" do + let(:include_path) { include_path_ } + let(:file_path) { file_path_ } + + it 'includes content of the file' do + is_expected.to include('

    Include this:

    ') + is_expected.to include("

    Content from #{include_path}

    ") + end end end end - end - context 'when requested path is a file in the repo' do - let(:requested_path) { 'doc/api/README.adoc' } + context 'when requested path is a file in the repo' do + let(:requested_path) { 'doc/api/README.adoc' } - include_examples :valid_include + include_examples :valid_include - context 'without a commit (only ref)' do - let(:commit) { nil } + context 'without a commit (only ref)' do + let(:commit) { nil } - include_examples :valid_include + include_examples :valid_include + end end - end - context 'when requested path is a directory in the repo' do - let(:requested_path) { 'doc/api/' } + context 'when requested path is a directory in the repo' do + let(:requested_path) { 'doc/api/' } - include_examples :valid_include + include_examples :valid_include - context 'without a commit (only ref)' do - let(:commit) { nil } + context 'without a commit (only ref)' do + let(:commit) { nil } - include_examples :valid_include + include_examples :valid_include + end end end - end - - context 'when repository is passed into the context' do - let(:wiki_repo) { project.wiki.repository } - let(:include_path) { 'wiki_file.adoc' } - before do - project.create_wiki - context.merge!(repository: wiki_repo) - end + context 'when repository is passed into the context' do + let(:wiki_repo) { project.wiki.repository } + let(:include_path) { 'wiki_file.adoc' } - context 'when the file exists' do before do - create_file(include_path, 'Content from wiki', repository: wiki_repo) + project.create_wiki + context.merge!(repository: wiki_repo) end - it { is_expected.to include('

    Content from wiki

    ') } - end - - context 'when the file does not exist' do - it { is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]")} - end - end - - context 'recursive includes with relative paths' do - let(:input) do - <<~ADOC - Source: requested file + context 'when the file exists' do + before do + create_file(include_path, 'Content from wiki', repository: wiki_repo) + end - include::doc/README.adoc[] + it { is_expected.to include('

    Content from wiki

    ') } + end - include::license.adoc[] - ADOC + context 'when the file does not exist' do + it { is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]")} + end end - before do - create_file 'doc/README.adoc', <<~ADOC - Source: doc/README.adoc - - include::../license.adoc[] + context 'recursive includes with relative paths' do + let(:input) do + <<~ADOC + Source: requested file + + include::doc/README.adoc[] + + include::license.adoc[] + ADOC + end - include::api/hello.adoc[] - ADOC - create_file 'license.adoc', <<~ADOC - Source: license.adoc - ADOC - create_file 'doc/api/hello.adoc', <<~ADOC - Source: doc/api/hello.adoc + before do + create_file 'doc/README.adoc', <<~ADOC + Source: doc/README.adoc + + include::../license.adoc[] + + include::api/hello.adoc[] + ADOC + create_file 'license.adoc', <<~ADOC + Source: license.adoc + ADOC + create_file 'doc/api/hello.adoc', <<~ADOC + Source: doc/api/hello.adoc + + include::./common.adoc[] + ADOC + create_file 'doc/api/common.adoc', <<~ADOC + Source: doc/api/common.adoc + ADOC + end - include::./common.adoc[] - ADOC - create_file 'doc/api/common.adoc', <<~ADOC - Source: doc/api/common.adoc - ADOC + it 'includes content of the included files recursively' do + expect(output.gsub(/<[^>]+>/, '').gsub(/\n\s*/, "\n").strip).to eq <<~ADOC.strip + Source: requested file + Source: doc/README.adoc + Source: license.adoc + Source: doc/api/hello.adoc + Source: doc/api/common.adoc + Source: license.adoc + ADOC + end end - it 'includes content of the included files recursively' do - expect(output.gsub(/<[^>]+>/, '').gsub(/\n\s*/, "\n").strip).to eq <<~ADOC.strip - Source: requested file - Source: doc/README.adoc - Source: license.adoc - Source: doc/api/hello.adoc - Source: doc/api/common.adoc - Source: license.adoc - ADOC + def create_file(path, content, repository: project.repository) + repository.create_file(project.creator, path, content, + message: "Add #{path}", branch_name: 'asciidoc') end end + end + end - def create_file(path, content, repository: project.repository) - repository.create_file(project.creator, path, content, - message: "Add #{path}", branch_name: 'asciidoc') - end + context 'using ruby-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: false) + end + + it_behaves_like 'renders correct asciidoc' + end + + context 'using c-based HTML renderer' do + before do + stub_feature_flags(use_cmark_renderer: true) end + + it_behaves_like 'renders correct asciidoc' end def render(*args) diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index b0522e269e0..f1c891b2adb 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -873,45 +873,65 @@ RSpec.describe Gitlab::Auth::AuthFinders do end describe '#find_user_from_job_token' do + let(:token) { job.token } + subject { find_user_from_job_token } - context 'when the token is in the headers' do - before do - set_header(described_class::JOB_TOKEN_HEADER, token) + shared_examples 'finds user when job token allowed' do + context 'when the token is in the headers' do + before do + set_header(described_class::JOB_TOKEN_HEADER, token) + end + + it_behaves_like 'find user from job token' end - it_behaves_like 'find user from job token' - end + context 'when the token is in the job_token param' do + before do + set_param(described_class::JOB_TOKEN_PARAM, token) + end - context 'when the token is in the job_token param' do - before do - set_param(described_class::JOB_TOKEN_PARAM, token) + it_behaves_like 'find user from job token' end - it_behaves_like 'find user from job token' - end + context 'when the token is in the token param' do + before do + set_param(described_class::RUNNER_JOB_TOKEN_PARAM, token) + end - context 'when the token is in the token param' do - before do - set_param(described_class::RUNNER_JOB_TOKEN_PARAM, token) + it_behaves_like 'find user from job token' end + end - it_behaves_like 'find user from job token' + context 'when route setting allows job_token' do + let(:route_authentication_setting) { { job_token_allowed: true } } + + include_examples 'finds user when job token allowed' end - context 'when the job token is provided via basic auth' do + context 'when route setting is basic auth' do let(:route_authentication_setting) { { job_token_allowed: :basic_auth } } - let(:username) { ::Gitlab::Auth::CI_JOB_USER } - let(:token) { job.token } - before do - set_basic_auth_header(username, token) + context 'when the token is provided via basic auth' do + let(:username) { ::Gitlab::Auth::CI_JOB_USER } + + before do + set_basic_auth_header(username, token) + end + + it { is_expected.to eq(user) } end - it { is_expected.to eq(user) } + include_examples 'finds user when job token allowed' + end - context 'credentials are provided but route setting is incorrect' do - let(:route_authentication_setting) { { job_token_allowed: :unknown } } + context 'when route setting job_token_allowed is invalid' do + let(:route_authentication_setting) { { job_token_allowed: false } } + + context 'when the token is provided' do + before do + set_header(described_class::JOB_TOKEN_HEADER, token) + end it { is_expected.to be_nil } end diff --git a/spec/lib/gitlab/background_migration/add_modified_to_approval_merge_request_rule_spec.rb b/spec/lib/gitlab/background_migration/add_modified_to_approval_merge_request_rule_spec.rb index 81b8b5dde08..0b29163671c 100644 --- a/spec/lib/gitlab/background_migration/add_modified_to_approval_merge_request_rule_spec.rb +++ b/spec/lib/gitlab/background_migration/add_modified_to_approval_merge_request_rule_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::AddModifiedToApprovalMergeRequestRule, schema: 20200817195628 do +RSpec.describe Gitlab::BackgroundMigration::AddModifiedToApprovalMergeRequestRule, schema: 20181228175414 do let(:determine_if_rules_are_modified) { described_class.new } let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab') } diff --git a/spec/lib/gitlab/background_migration/add_primary_email_to_emails_if_user_confirmed_spec.rb b/spec/lib/gitlab/background_migration/add_primary_email_to_emails_if_user_confirmed_spec.rb new file mode 100644 index 00000000000..b50a55a9e41 --- /dev/null +++ b/spec/lib/gitlab/background_migration/add_primary_email_to_emails_if_user_confirmed_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::AddPrimaryEmailToEmailsIfUserConfirmed do + let(:users) { table(:users) } + let(:emails) { table(:emails) } + + let!(:unconfirmed_user) { users.create!(name: 'unconfirmed', email: 'unconfirmed@example.com', confirmed_at: nil, projects_limit: 100) } + let!(:confirmed_user_1) { users.create!(name: 'confirmed-1', email: 'confirmed-1@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:confirmed_user_2) { users.create!(name: 'confirmed-2', email: 'confirmed-2@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:email) { emails.create!(user_id: confirmed_user_1.id, email: 'confirmed-1@example.com', confirmed_at: 1.day.ago) } + + let(:perform) { described_class.new.perform(users.first.id, users.last.id) } + + it 'adds the primary email of confirmed users to Emails, unless already added', :aggregate_failures do + expect(emails.where(email: [unconfirmed_user.email, confirmed_user_2.email])).to be_empty + + expect { perform }.not_to raise_error + + expect(emails.where(email: unconfirmed_user.email).count).to eq(0) + expect(emails.where(email: confirmed_user_1.email, user_id: confirmed_user_1.id).count).to eq(1) + expect(emails.where(email: confirmed_user_2.email, user_id: confirmed_user_2.id).count).to eq(1) + + email_2 = emails.find_by(email: confirmed_user_2.email, user_id: confirmed_user_2.id) + expect(email_2.confirmed_at).to eq(confirmed_user_2.reload.confirmed_at) + end + + it 'sets timestamps on the created Emails' do + perform + + email_2 = emails.find_by(email: confirmed_user_2.email, user_id: confirmed_user_2.id) + + expect(email_2.created_at).not_to be_nil + expect(email_2.updated_at).not_to be_nil + end + + context 'when a range of IDs is specified' do + let!(:confirmed_user_3) { users.create!(name: 'confirmed-3', email: 'confirmed-3@example.com', confirmed_at: 1.hour.ago, projects_limit: 100) } + let!(:confirmed_user_4) { users.create!(name: 'confirmed-4', email: 'confirmed-4@example.com', confirmed_at: 1.hour.ago, projects_limit: 100) } + + it 'only acts on the specified range of IDs', :aggregate_failures do + expect do + described_class.new.perform(confirmed_user_2.id, confirmed_user_3.id) + end.to change { Email.count }.by(2) + expect(emails.where(email: confirmed_user_4.email).count).to eq(0) + end + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb b/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb index 49fa7b41916..6ab1e3ecd70 100644 --- a/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillArtifactExpiryDate, :migration, schema: 20201111152859 do +RSpec.describe Gitlab::BackgroundMigration::BackfillArtifactExpiryDate, :migration, schema: 20181228175414 do subject(:perform) { migration.perform(1, 99) } let(:migration) { described_class.new } diff --git a/spec/lib/gitlab/background_migration/backfill_deployment_clusters_from_deployments_spec.rb b/spec/lib/gitlab/background_migration/backfill_deployment_clusters_from_deployments_spec.rb index 54c14e7a4b8..1404ada3647 100644 --- a/spec/lib/gitlab/background_migration/backfill_deployment_clusters_from_deployments_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_deployment_clusters_from_deployments_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillDeploymentClustersFromDeployments, :migration, schema: 20200227140242 do +RSpec.describe Gitlab::BackgroundMigration::BackfillDeploymentClustersFromDeployments, :migration, schema: 20181228175414 do subject { described_class.new } describe '#perform' do diff --git a/spec/lib/gitlab/background_migration/backfill_design_internal_ids_spec.rb b/spec/lib/gitlab/background_migration/backfill_design_internal_ids_spec.rb deleted file mode 100644 index 4bf59a02a31..00000000000 --- a/spec/lib/gitlab/background_migration/backfill_design_internal_ids_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::BackfillDesignInternalIds, :migration, schema: 20201030203854 do - subject { described_class.new(designs) } - - let_it_be(:namespaces) { table(:namespaces) } - let_it_be(:projects) { table(:projects) } - let_it_be(:designs) { table(:design_management_designs) } - - let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let(:project) { projects.create!(namespace_id: namespace.id) } - let(:project_2) { projects.create!(namespace_id: namespace.id) } - - def create_design!(proj = project) - designs.create!(project_id: proj.id, filename: generate(:filename)) - end - - def migrate! - relation = designs.where(project_id: [project.id, project_2.id]).select(:project_id).distinct - - subject.perform(relation) - end - - it 'backfills the iid for designs' do - 3.times { create_design! } - - expect do - migrate! - end.to change { designs.pluck(:iid) }.from(contain_exactly(nil, nil, nil)).to(contain_exactly(1, 2, 3)) - end - - it 'scopes IIDs and handles range and starting-point correctly' do - create_design!.update!(iid: 10) - create_design!.update!(iid: 12) - create_design!(project_2).update!(iid: 7) - project_3 = projects.create!(namespace_id: namespace.id) - - 2.times { create_design! } - 2.times { create_design!(project_2) } - 2.times { create_design!(project_3) } - - migrate! - - expect(designs.where(project_id: project.id).pluck(:iid)).to contain_exactly(10, 12, 13, 14) - expect(designs.where(project_id: project_2.id).pluck(:iid)).to contain_exactly(7, 8, 9) - expect(designs.where(project_id: project_3.id).pluck(:iid)).to contain_exactly(nil, nil) - end - - it 'updates the internal ID records' do - design = create_design! - 2.times { create_design! } - design.update!(iid: 10) - scope = { project_id: project.id } - usage = :design_management_designs - init = ->(_d, _s) { 0 } - - ::InternalId.track_greatest(design, scope, usage, 10, init) - - migrate! - - next_iid = ::InternalId.generate_next(design, scope, usage, init) - - expect(designs.pluck(:iid)).to contain_exactly(10, 11, 12) - expect(design.reload.iid).to eq(10) - expect(next_iid).to eq(13) - end -end diff --git a/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb b/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb index 550bdc484c9..9194525e713 100644 --- a/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillEnvironmentIdDeploymentMergeRequests, schema: 20200312134637 do +RSpec.describe Gitlab::BackgroundMigration::BackfillEnvironmentIdDeploymentMergeRequests, schema: 20181228175414 do let(:environments) { table(:environments) } let(:merge_requests) { table(:merge_requests) } let(:deployments) { table(:deployments) } diff --git a/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb b/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb index 58864aac084..446d62bbd2a 100644 --- a/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillJiraTrackerDeploymentType2, :migration, schema: 20201028182809 do +RSpec.describe Gitlab::BackgroundMigration::BackfillJiraTrackerDeploymentType2, :migration, schema: 20181228175414 do let_it_be(:jira_integration_temp) { described_class::JiraServiceTemp } let_it_be(:jira_tracker_data_temp) { described_class::JiraTrackerDataTemp } let_it_be(:atlassian_host) { 'https://api.atlassian.net' } diff --git a/spec/lib/gitlab/background_migration/backfill_merge_request_cleanup_schedules_spec.rb b/spec/lib/gitlab/background_migration/backfill_merge_request_cleanup_schedules_spec.rb index c2daa35703d..d33f52514da 100644 --- a/spec/lib/gitlab/background_migration/backfill_merge_request_cleanup_schedules_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_merge_request_cleanup_schedules_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillMergeRequestCleanupSchedules, schema: 20201103110018 do +RSpec.describe Gitlab::BackgroundMigration::BackfillMergeRequestCleanupSchedules, schema: 20181228175414 do let(:merge_requests) { table(:merge_requests) } let(:cleanup_schedules) { table(:merge_request_cleanup_schedules) } let(:metrics) { table(:merge_request_metrics) } diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_settings_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_settings_spec.rb index 43e76a2952e..0f8adca2ca4 100644 --- a/spec/lib/gitlab/background_migration/backfill_namespace_settings_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_namespace_settings_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceSettings, schema: 20200703125016 do +RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceSettings, schema: 20181228175414 do let(:namespaces) { table(:namespaces) } let(:namespace_settings) { table(:namespace_settings) } let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } diff --git a/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb index 48c5674822a..e6b0db2ab73 100644 --- a/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillProjectSettings, schema: 20200114113341 do +RSpec.describe Gitlab::BackgroundMigration::BackfillProjectSettings, schema: 20181228175414 do let(:projects) { table(:projects) } let(:project_settings) { table(:project_settings) } let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } diff --git a/spec/lib/gitlab/background_migration/backfill_push_rules_id_in_projects_spec.rb b/spec/lib/gitlab/background_migration/backfill_push_rules_id_in_projects_spec.rb index 9ce6a3227b5..3468df3dccd 100644 --- a/spec/lib/gitlab/background_migration/backfill_push_rules_id_in_projects_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_push_rules_id_in_projects_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillPushRulesIdInProjects, :migration, schema: 2020_03_25_162730 do +RSpec.describe Gitlab::BackgroundMigration::BackfillPushRulesIdInProjects, :migration, schema: 20181228175414 do let(:push_rules) { table(:push_rules) } let(:projects) { table(:projects) } let(:project_settings) { table(:project_settings) } diff --git a/spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb b/spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb new file mode 100644 index 00000000000..395248b786d --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_user_namespace_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillUserNamespace, :migration, schema: 20210930211936 do + let(:migration) { described_class.new } + let(:namespaces_table) { table(:namespaces) } + + let(:table_name) { 'namespaces' } + let(:batch_column) { :id } + let(:sub_batch_size) { 100 } + let(:pause_ms) { 0 } + + subject(:perform_migration) { migration.perform(1, 10, table_name, batch_column, sub_batch_size, pause_ms) } + + before do + namespaces_table.create!(id: 1, name: 'test1', path: 'test1', type: nil) + namespaces_table.create!(id: 2, name: 'test2', path: 'test2', type: 'User') + namespaces_table.create!(id: 3, name: 'test3', path: 'test3', type: 'Group') + namespaces_table.create!(id: 4, name: 'test4', path: 'test4', type: nil) + namespaces_table.create!(id: 11, name: 'test11', path: 'test11', type: nil) + end + + it 'backfills `type` for the selected records', :aggregate_failures do + queries = ActiveRecord::QueryRecorder.new do + perform_migration + end + + expect(queries.count).to eq(3) + expect(namespaces_table.where(type: 'User').count).to eq 3 + expect(namespaces_table.where(type: 'User').pluck(:id)).to match_array([1, 2, 4]) + end + + it 'tracks timings of queries' do + expect(migration.batch_metrics.timings).to be_empty + + expect { perform_migration }.to change { migration.batch_metrics.timings } + end +end diff --git a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb index 3e378db04d4..d4fc24d0559 100644 --- a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb +++ b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob do - let(:table_name) { :copy_primary_key_test } + let(:table_name) { :_test_copy_primary_key_test } let(:test_table) { table(table_name) } let(:sub_batch_size) { 1000 } let(:pause_ms) { 0 } diff --git a/spec/lib/gitlab/background_migration/copy_merge_request_target_project_to_merge_request_metrics_spec.rb b/spec/lib/gitlab/background_migration/copy_merge_request_target_project_to_merge_request_metrics_spec.rb deleted file mode 100644 index 71bb794d539..00000000000 --- a/spec/lib/gitlab/background_migration/copy_merge_request_target_project_to_merge_request_metrics_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::CopyMergeRequestTargetProjectToMergeRequestMetrics, :migration, schema: 20200723125205 do - let(:migration) { described_class.new } - - let_it_be(:namespaces) { table(:namespaces) } - let_it_be(:projects) { table(:projects) } - let_it_be(:merge_requests) { table(:merge_requests) } - let_it_be(:metrics) { table(:merge_request_metrics) } - - let!(:namespace) { namespaces.create!(name: 'namespace', path: 'namespace') } - let!(:project_1) { projects.create!(namespace_id: namespace.id) } - let!(:project_2) { projects.create!(namespace_id: namespace.id) } - let!(:merge_request_to_migrate_1) { merge_requests.create!(source_branch: 'a', target_branch: 'b', target_project_id: project_1.id) } - let!(:merge_request_to_migrate_2) { merge_requests.create!(source_branch: 'c', target_branch: 'd', target_project_id: project_2.id) } - let!(:merge_request_without_metrics) { merge_requests.create!(source_branch: 'e', target_branch: 'f', target_project_id: project_2.id) } - - let!(:metrics_1) { metrics.create!(merge_request_id: merge_request_to_migrate_1.id) } - let!(:metrics_2) { metrics.create!(merge_request_id: merge_request_to_migrate_2.id) } - - let(:merge_request_ids) { [merge_request_to_migrate_1.id, merge_request_to_migrate_2.id, merge_request_without_metrics.id] } - - subject { migration.perform(merge_request_ids.min, merge_request_ids.max) } - - it 'copies `target_project_id` to the associated `merge_request_metrics` record' do - subject - - expect(metrics_1.reload.target_project_id).to eq(project_1.id) - expect(metrics_2.reload.target_project_id).to eq(project_2.id) - end - - it 'does not create metrics record when it is missing' do - subject - - expect(metrics.find_by_merge_request_id(merge_request_without_metrics.id)).to be_nil - end -end diff --git a/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb b/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb index c4beb719e1e..b83dc6fff7a 100644 --- a/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb +++ b/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::DropInvalidVulnerabilities, schema: 20201110110454 do +RSpec.describe Gitlab::BackgroundMigration::DropInvalidVulnerabilities, schema: 20181228175414 do let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } let_it_be(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let_it_be(:users) { table(:users) } diff --git a/spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb b/spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb new file mode 100644 index 00000000000..c343ee438b8 --- /dev/null +++ b/spec/lib/gitlab/background_migration/fix_merge_request_diff_commit_users_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# The underlying migration relies on the global models (e.g. Project). This +# means we also need to use FactoryBot factories to ensure everything is +# operating using the same types. If we use `table()` and similar methods we +# would have to duplicate a lot of logic just for these tests. +# +# rubocop: disable RSpec/FactoriesInMigrationSpecs +RSpec.describe Gitlab::BackgroundMigration::FixMergeRequestDiffCommitUsers do + let(:migration) { described_class.new } + + describe '#perform' do + context 'when the project exists' do + it 'processes the project' do + project = create(:project) + + expect(migration).to receive(:process).with(project) + expect(migration).to receive(:schedule_next_job) + + migration.perform(project.id) + end + + it 'marks the background job as finished' do + project = create(:project) + + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: 'FixMergeRequestDiffCommitUsers', + arguments: [project.id] + ) + + migration.perform(project.id) + + job = Gitlab::Database::BackgroundMigrationJob + .find_by(class_name: 'FixMergeRequestDiffCommitUsers') + + expect(job.status).to eq('succeeded') + end + end + + context 'when the project does not exist' do + it 'does nothing' do + expect(migration).not_to receive(:process) + expect(migration).to receive(:schedule_next_job) + + migration.perform(-1) + end + end + end + + describe '#process' do + it 'processes the merge requests of the project' do + project = create(:project, :repository) + commit = project.commit + mr = create( + :merge_request_with_diffs, + source_project: project, + target_project: project + ) + + diff = mr.merge_request_diffs.first + + create( + :merge_request_diff_commit, + merge_request_diff: diff, + sha: commit.sha, + relative_order: 9000 + ) + + migration.process(project) + + updated = diff + .merge_request_diff_commits + .find_by(sha: commit.sha, relative_order: 9000) + + expect(updated.commit_author_id).not_to be_nil + expect(updated.committer_id).not_to be_nil + end + end + + describe '#update_commit' do + let(:project) { create(:project, :repository) } + let(:mr) do + create( + :merge_request_with_diffs, + source_project: project, + target_project: project + ) + end + + let(:diff) { mr.merge_request_diffs.first } + let(:commit) { project.commit } + + def update_row(migration, project, diff, row) + migration.update_commit(project, row) + + diff + .merge_request_diff_commits + .find_by(sha: row.sha, relative_order: row.relative_order) + end + + it 'populates missing commit authors' do + commit_row = create( + :merge_request_diff_commit, + merge_request_diff: diff, + sha: commit.sha, + relative_order: 9000 + ) + + updated = update_row(migration, project, diff, commit_row) + + expect(updated.commit_author.name).to eq(commit.to_hash[:author_name]) + expect(updated.commit_author.email).to eq(commit.to_hash[:author_email]) + end + + it 'populates missing committers' do + commit_row = create( + :merge_request_diff_commit, + merge_request_diff: diff, + sha: commit.sha, + relative_order: 9000 + ) + + updated = update_row(migration, project, diff, commit_row) + + expect(updated.committer.name).to eq(commit.to_hash[:committer_name]) + expect(updated.committer.email).to eq(commit.to_hash[:committer_email]) + end + + it 'leaves existing commit authors as-is' do + user = create(:merge_request_diff_commit_user) + commit_row = create( + :merge_request_diff_commit, + merge_request_diff: diff, + sha: commit.sha, + relative_order: 9000, + commit_author: user + ) + + updated = update_row(migration, project, diff, commit_row) + + expect(updated.commit_author).to eq(user) + end + + it 'leaves existing committers as-is' do + user = create(:merge_request_diff_commit_user) + commit_row = create( + :merge_request_diff_commit, + merge_request_diff: diff, + sha: commit.sha, + relative_order: 9000, + committer: user + ) + + updated = update_row(migration, project, diff, commit_row) + + expect(updated.committer).to eq(user) + end + + it 'does nothing when both the author and committer are present' do + user = create(:merge_request_diff_commit_user) + commit_row = create( + :merge_request_diff_commit, + merge_request_diff: diff, + sha: commit.sha, + relative_order: 9000, + committer: user, + commit_author: user + ) + + recorder = ActiveRecord::QueryRecorder.new do + migration.update_commit(project, commit_row) + end + + expect(recorder.count).to be_zero + end + + it 'does nothing if the commit does not exist in Git' do + user = create(:merge_request_diff_commit_user) + commit_row = create( + :merge_request_diff_commit, + merge_request_diff: diff, + sha: 'kittens', + relative_order: 9000, + committer: user, + commit_author: user + ) + + recorder = ActiveRecord::QueryRecorder.new do + migration.update_commit(project, commit_row) + end + + expect(recorder.count).to be_zero + end + + it 'does nothing when the committer/author are missing in the Git commit' do + user = create(:merge_request_diff_commit_user) + commit_row = create( + :merge_request_diff_commit, + merge_request_diff: diff, + sha: commit.sha, + relative_order: 9000, + committer: user, + commit_author: user + ) + + allow(migration).to receive(:find_or_create_user).and_return(nil) + + recorder = ActiveRecord::QueryRecorder.new do + migration.update_commit(project, commit_row) + end + + expect(recorder.count).to be_zero + end + end + + describe '#schedule_next_job' do + it 'schedules the next background migration' do + Gitlab::Database::BackgroundMigrationJob + .create!(class_name: 'FixMergeRequestDiffCommitUsers', arguments: [42]) + + expect(BackgroundMigrationWorker) + .to receive(:perform_in) + .with(2.minutes, 'FixMergeRequestDiffCommitUsers', [42]) + + migration.schedule_next_job + end + + it 'does nothing when there are no jobs' do + expect(BackgroundMigrationWorker) + .not_to receive(:perform_in) + + migration.schedule_next_job + end + end + + describe '#find_commit' do + let(:project) { create(:project, :repository) } + + it 'finds a commit using Git' do + commit = project.commit + found = migration.find_commit(project, commit.sha) + + expect(found).to eq(commit.to_hash) + end + + it 'caches the results' do + commit = project.commit + + migration.find_commit(project, commit.sha) + + expect { migration.find_commit(project, commit.sha) } + .not_to change { Gitlab::GitalyClient.get_request_count } + end + + it 'returns an empty hash if the commit does not exist' do + expect(migration.find_commit(project, 'kittens')).to eq({}) + end + end + + describe '#find_or_create_user' do + let(:project) { create(:project, :repository) } + + it 'creates missing users' do + commit = project.commit.to_hash + id = migration.find_or_create_user(commit, :author_name, :author_email) + + expect(MergeRequest::DiffCommitUser.count).to eq(1) + + created = MergeRequest::DiffCommitUser.first + + expect(created.name).to eq(commit[:author_name]) + expect(created.email).to eq(commit[:author_email]) + expect(created.id).to eq(id) + end + + it 'returns users that already exist' do + commit = project.commit.to_hash + user1 = migration.find_or_create_user(commit, :author_name, :author_email) + user2 = migration.find_or_create_user(commit, :author_name, :author_email) + + expect(user1).to eq(user2) + end + + it 'caches the results' do + commit = project.commit.to_hash + + migration.find_or_create_user(commit, :author_name, :author_email) + + recorder = ActiveRecord::QueryRecorder.new do + migration.find_or_create_user(commit, :author_name, :author_email) + end + + expect(recorder.count).to be_zero + end + + it 'returns nil if the commit details are missing' do + id = migration.find_or_create_user({}, :author_name, :author_email) + + expect(id).to be_nil + end + end + + describe '#matches_row' do + it 'returns the query matches for the composite primary key' do + row = double(:commit, merge_request_diff_id: 4, relative_order: 5) + arel = migration.matches_row(row) + + expect(arel.to_sql).to eq( + '("merge_request_diff_commits"."merge_request_diff_id", "merge_request_diff_commits"."relative_order") = (4, 5)' + ) + end + end +end +# rubocop: enable RSpec/FactoriesInMigrationSpecs diff --git a/spec/lib/gitlab/background_migration/fix_projects_without_project_feature_spec.rb b/spec/lib/gitlab/background_migration/fix_projects_without_project_feature_spec.rb deleted file mode 100644 index d503824041b..00000000000 --- a/spec/lib/gitlab/background_migration/fix_projects_without_project_feature_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::FixProjectsWithoutProjectFeature, schema: 2020_01_27_111840 do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:project_features) { table(:project_features) } - - let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - - let!(:project) { projects.create!(namespace_id: namespace.id) } - let(:private_project_without_feature) { projects.create!(namespace_id: namespace.id, visibility_level: 0) } - let(:public_project_without_feature) { projects.create!(namespace_id: namespace.id, visibility_level: 20) } - let!(:projects_without_feature) { [private_project_without_feature, public_project_without_feature] } - - before do - project_features.create!({ project_id: project.id, pages_access_level: 20 }) - end - - subject { described_class.new.perform(Project.minimum(:id), Project.maximum(:id)) } - - def project_feature_records - project_features.order(:project_id).pluck(:project_id) - end - - def features(project) - project_features.find_by(project_id: project.id)&.attributes - end - - it 'creates a ProjectFeature for projects without it' do - expect { subject }.to change { project_feature_records }.from([project.id]).to([project.id, *projects_without_feature.map(&:id)]) - end - - it 'creates ProjectFeature records with default values for a public project' do - subject - - expect(features(public_project_without_feature)).to include( - { - "merge_requests_access_level" => 20, - "issues_access_level" => 20, - "wiki_access_level" => 20, - "snippets_access_level" => 20, - "builds_access_level" => 20, - "repository_access_level" => 20, - "pages_access_level" => 20, - "forking_access_level" => 20 - } - ) - end - - it 'creates ProjectFeature records with default values for a private project' do - subject - - expect(features(private_project_without_feature)).to include("pages_access_level" => 10) - end - - context 'when access control to pages is forced' do - before do - allow(::Gitlab::Pages).to receive(:access_control_is_forced?).and_return(true) - end - - it 'creates ProjectFeature records with default values for a public project' do - subject - - expect(features(public_project_without_feature)).to include("pages_access_level" => 10) - end - end - - it 'sets created_at/updated_at timestamps' do - subject - - expect(project_features.where('created_at IS NULL OR updated_at IS NULL')).to be_empty - end -end diff --git a/spec/lib/gitlab/background_migration/fix_projects_without_prometheus_service_spec.rb b/spec/lib/gitlab/background_migration/fix_projects_without_prometheus_service_spec.rb deleted file mode 100644 index 9a497a9e01a..00000000000 --- a/spec/lib/gitlab/background_migration/fix_projects_without_prometheus_service_spec.rb +++ /dev/null @@ -1,234 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::FixProjectsWithoutPrometheusService, :migration, schema: 2020_02_20_115023 do - def service_params_for(project_id, params = {}) - { - project_id: project_id, - active: false, - properties: '{}', - type: 'PrometheusService', - template: false, - push_events: true, - issues_events: true, - merge_requests_events: true, - tag_push_events: true, - note_events: true, - category: 'monitoring', - default: false, - wiki_page_events: true, - pipeline_events: true, - confidential_issues_events: true, - commit_events: true, - job_events: true, - confidential_note_events: true, - deployment_events: false - }.merge(params) - end - - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:services) { table(:services) } - let(:clusters) { table(:clusters) } - let(:cluster_groups) { table(:cluster_groups) } - let(:clusters_applications_prometheus) { table(:clusters_applications_prometheus) } - let(:namespace) { namespaces.create!(name: 'user', path: 'user') } - let(:project) { projects.create!(namespace_id: namespace.id) } - - let(:application_statuses) do - { - errored: -1, - installed: 3, - updated: 5 - } - end - - let(:cluster_types) do - { - instance_type: 1, - group_type: 2, - project_type: 3 - } - end - - let(:columns) do - %w(project_id active properties type template push_events - issues_events merge_requests_events tag_push_events - note_events category default wiki_page_events pipeline_events - confidential_issues_events commit_events job_events - confidential_note_events deployment_events) - end - - describe '#perform' do - shared_examples 'fix services entries state' do - it 'is idempotent' do - expect { subject.perform(project.id, project.id + 1) }.to change { services.order(:id).map { |row| row.attributes } } - - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.order(:id).map { |row| row.attributes } } - end - - context 'non prometheus services' do - it 'does not change them' do - other_type = 'SomeOtherService' - services.create!(service_params_for(project.id, active: true, type: other_type)) - - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.where(type: other_type).order(:id).map { |row| row.attributes } } - end - end - - context 'prometheus integration services do not exist' do - it 'creates missing services entries', :aggregate_failures do - expect { subject.perform(project.id, project.id + 1) }.to change { services.count }.by(1) - expect([service_params_for(project.id, active: true)]).to eq services.order(:id).map { |row| row.attributes.slice(*columns).symbolize_keys } - end - - context 'template is present for prometheus services' do - it 'creates missing services entries', :aggregate_failures do - services.create!(service_params_for(nil, template: true, properties: { 'from_template' => true }.to_json)) - - expect { subject.perform(project.id, project.id + 1) }.to change { services.count }.by(1) - updated_rows = services.where(template: false).order(:id).map { |row| row.attributes.slice(*columns).symbolize_keys } - expect([service_params_for(project.id, active: true, properties: { 'from_template' => true }.to_json)]).to eq updated_rows - end - end - end - - context 'prometheus integration services exist' do - context 'in active state' do - it 'does not change them' do - services.create!(service_params_for(project.id, active: true)) - - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.order(:id).map { |row| row.attributes } } - end - end - - context 'not in active state' do - it 'sets active attribute to true' do - service = services.create!(service_params_for(project.id, active: false)) - - expect { subject.perform(project.id, project.id + 1) }.to change { service.reload.active? }.from(false).to(true) - end - - context 'prometheus services are configured manually ' do - it 'does not change them' do - properties = '{"api_url":"http://test.dev","manual_configuration":"1"}' - services.create!(service_params_for(project.id, properties: properties, active: false)) - - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.order(:id).map { |row| row.attributes } } - end - end - end - end - end - - context 'k8s cluster shared on instance level' do - let(:cluster) { clusters.create!(name: 'cluster', cluster_type: cluster_types[:instance_type]) } - - context 'with installed prometheus application' do - before do - clusters_applications_prometheus.create!(cluster_id: cluster.id, status: application_statuses[:installed], version: '123') - end - - it_behaves_like 'fix services entries state' - end - - context 'with updated prometheus application' do - before do - clusters_applications_prometheus.create!(cluster_id: cluster.id, status: application_statuses[:updated], version: '123') - end - - it_behaves_like 'fix services entries state' - end - - context 'with errored prometheus application' do - before do - clusters_applications_prometheus.create!(cluster_id: cluster.id, status: application_statuses[:errored], version: '123') - end - - it 'does not change services entries' do - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.order(:id).map { |row| row.attributes } } - end - end - end - - context 'k8s cluster shared on group level' do - let(:cluster) { clusters.create!(name: 'cluster', cluster_type: cluster_types[:group_type]) } - - before do - cluster_groups.create!(cluster_id: cluster.id, group_id: project.namespace_id) - end - - context 'with installed prometheus application' do - before do - clusters_applications_prometheus.create!(cluster_id: cluster.id, status: application_statuses[:installed], version: '123') - end - - it_behaves_like 'fix services entries state' - - context 'second k8s cluster without application available' do - let(:namespace_2) { namespaces.create!(name: 'namespace2', path: 'namespace2') } - let(:project_2) { projects.create!(namespace_id: namespace_2.id) } - - before do - cluster_2 = clusters.create!(name: 'cluster2', cluster_type: cluster_types[:group_type]) - cluster_groups.create!(cluster_id: cluster_2.id, group_id: project_2.namespace_id) - end - - it 'changed only affected services entries' do - expect { subject.perform(project.id, project_2.id + 1) }.to change { services.count }.by(1) - expect([service_params_for(project.id, active: true)]).to eq services.order(:id).map { |row| row.attributes.slice(*columns).symbolize_keys } - end - end - end - - context 'with updated prometheus application' do - before do - clusters_applications_prometheus.create!(cluster_id: cluster.id, status: application_statuses[:updated], version: '123') - end - - it_behaves_like 'fix services entries state' - end - - context 'with errored prometheus application' do - before do - clusters_applications_prometheus.create!(cluster_id: cluster.id, status: application_statuses[:errored], version: '123') - end - - it 'does not change services entries' do - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.order(:id).map { |row| row.attributes } } - end - end - - context 'with missing prometheus application' do - it 'does not change services entries' do - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.order(:id).map { |row| row.attributes } } - end - - context 'with inactive service' do - it 'does not change services entries' do - services.create!(service_params_for(project.id)) - - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.order(:id).map { |row| row.attributes } } - end - end - end - end - - context 'k8s cluster for single project' do - let(:cluster) { clusters.create!(name: 'cluster', cluster_type: cluster_types[:project_type]) } - let(:cluster_projects) { table(:cluster_projects) } - - context 'with installed prometheus application' do - before do - cluster_projects.create!(cluster_id: cluster.id, project_id: project.id) - clusters_applications_prometheus.create!(cluster_id: cluster.id, status: application_statuses[:installed], version: '123') - end - - it 'does not change services entries' do - expect { subject.perform(project.id, project.id + 1) }.not_to change { services.order(:id).map { |row| row.attributes } } - end - end - end - end -end diff --git a/spec/lib/gitlab/background_migration/job_coordinator_spec.rb b/spec/lib/gitlab/background_migration/job_coordinator_spec.rb new file mode 100644 index 00000000000..a0543ca9958 --- /dev/null +++ b/spec/lib/gitlab/background_migration/job_coordinator_spec.rb @@ -0,0 +1,344 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::JobCoordinator do + let(:database) { :main } + let(:worker_class) { BackgroundMigrationWorker } + let(:coordinator) { described_class.new(database, worker_class) } + + describe '.for_database' do + it 'returns an executor with the correct worker class and database' do + coordinator = described_class.for_database(database) + + expect(coordinator.database).to eq(database) + expect(coordinator.worker_class).to eq(worker_class) + end + + context 'when passed in as a string' do + it 'retruns an executor with the correct worker class and database' do + coordinator = described_class.for_database(database.to_s) + + expect(coordinator.database).to eq(database) + expect(coordinator.worker_class).to eq(worker_class) + end + end + + context 'when an invalid value is given' do + it 'raises an error' do + expect do + described_class.for_database('notvalid') + end.to raise_error(ArgumentError, "database must be one of [main], got 'notvalid'") + end + end + end + + describe '#queue' do + it 'returns background migration worker queue' do + expect(coordinator.queue).to eq(worker_class.sidekiq_options['queue']) + end + end + + describe '#with_shared_connection' do + it 'yields to the block after properly configuring SharedModel' do + expect(Gitlab::Database::SharedModel).to receive(:using_connection) + .with(ActiveRecord::Base.connection).and_yield + + expect { |b| coordinator.with_shared_connection(&b) }.to yield_with_no_args + end + end + + describe '#steal' do + context 'when there are enqueued jobs present' do + let(:queue) do + [ + double(args: ['Foo', [10, 20]], klass: worker_class.name), + double(args: ['Bar', [20, 30]], klass: worker_class.name), + double(args: ['Foo', [20, 30]], klass: 'MergeWorker') + ] + end + + before do + allow(Sidekiq::Queue).to receive(:new) + .with(coordinator.queue) + .and_return(queue) + end + + context 'when queue contains unprocessed jobs' do + it 'steals jobs from a queue' do + expect(queue[0]).to receive(:delete).and_return(true) + + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) + + coordinator.steal('Foo') + end + + it 'sets up the shared connection while stealing jobs' do + connection = double('connection') + allow(coordinator).to receive(:connection).and_return(connection) + + expect(coordinator).to receive(:with_shared_connection).and_call_original + + expect(queue[0]).to receive(:delete).and_return(true) + + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) do + expect(Gitlab::Database::SharedModel.connection).to be(connection) + end + + coordinator.steal('Foo') do + expect(Gitlab::Database::SharedModel.connection).to be(connection) + + true # the job is only performed if the block returns true + end + end + + it 'does not steal job that has already been taken' do + expect(queue[0]).to receive(:delete).and_return(false) + + expect(coordinator).not_to receive(:perform) + + coordinator.steal('Foo') + end + + it 'does not steal jobs for a different migration' do + expect(coordinator).not_to receive(:perform) + + expect(queue[0]).not_to receive(:delete) + + coordinator.steal('Baz') + end + + context 'when a custom predicate is given' do + it 'steals jobs that match the predicate' do + expect(queue[0]).to receive(:delete).and_return(true) + + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) + + coordinator.steal('Foo') { |job| job.args.second.first == 10 && job.args.second.second == 20 } + end + + it 'does not steal jobs that do not match the predicate' do + expect(described_class).not_to receive(:perform) + + expect(queue[0]).not_to receive(:delete) + + coordinator.steal('Foo') { |(arg1, _)| arg1 == 5 } + end + end + end + + context 'when one of the jobs raises an error' do + let(:migration) { spy(:migration) } + + let(:queue) do + [double(args: ['Foo', [10, 20]], klass: worker_class.name), + double(args: ['Foo', [20, 30]], klass: worker_class.name)] + end + + before do + stub_const('Gitlab::BackgroundMigration::Foo', migration) + + allow(queue[0]).to receive(:delete).and_return(true) + allow(queue[1]).to receive(:delete).and_return(true) + end + + it 'enqueues the migration again and re-raises the error' do + allow(migration).to receive(:perform).with(10, 20).and_raise(Exception, 'Migration error').once + + expect(worker_class).to receive(:perform_async).with('Foo', [10, 20]).once + + expect { coordinator.steal('Foo') }.to raise_error(Exception) + end + end + end + + context 'when there are scheduled jobs present', :redis do + it 'steals all jobs from the scheduled sets' do + Sidekiq::Testing.disable! do + worker_class.perform_in(10.minutes, 'Object') + + expect(Sidekiq::ScheduledSet.new).to be_one + expect(coordinator).to receive(:perform).with('Object', any_args) + + coordinator.steal('Object') + + expect(Sidekiq::ScheduledSet.new).to be_none + end + end + end + + context 'when there are enqueued and scheduled jobs present', :redis do + it 'steals from the scheduled sets queue first' do + Sidekiq::Testing.disable! do + expect(coordinator).to receive(:perform).with('Object', [1]).ordered + expect(coordinator).to receive(:perform).with('Object', [2]).ordered + + worker_class.perform_async('Object', [2]) + worker_class.perform_in(10.minutes, 'Object', [1]) + + coordinator.steal('Object') + end + end + end + + context 'when retry_dead_jobs is true', :redis do + let(:retry_queue) do + [double(args: ['Object', [3]], klass: worker_class.name, delete: true)] + end + + let(:dead_queue) do + [double(args: ['Object', [4]], klass: worker_class.name, delete: true)] + end + + before do + allow(Sidekiq::RetrySet).to receive(:new).and_return(retry_queue) + allow(Sidekiq::DeadSet).to receive(:new).and_return(dead_queue) + end + + it 'steals from the dead and retry queue' do + Sidekiq::Testing.disable! do + expect(coordinator).to receive(:perform).with('Object', [1]).ordered + expect(coordinator).to receive(:perform).with('Object', [2]).ordered + expect(coordinator).to receive(:perform).with('Object', [3]).ordered + expect(coordinator).to receive(:perform).with('Object', [4]).ordered + + worker_class.perform_async('Object', [2]) + worker_class.perform_in(10.minutes, 'Object', [1]) + + coordinator.steal('Object', retry_dead_jobs: true) + end + end + end + end + + describe '#perform' do + let(:migration) { spy(:migration) } + let(:connection) { double('connection') } + + before do + stub_const('Gitlab::BackgroundMigration::Foo', migration) + + allow(coordinator).to receive(:connection).and_return(connection) + end + + it 'performs a background migration with the configured shared connection' do + expect(coordinator).to receive(:with_shared_connection).and_call_original + + expect(migration).to receive(:perform).with(10, 20).once do + expect(Gitlab::Database::SharedModel.connection).to be(connection) + end + + coordinator.perform('Foo', [10, 20]) + end + end + + describe '.remaining', :redis do + context 'when there are jobs remaining' do + before do + Sidekiq::Testing.disable! do + MergeWorker.perform_async('Foo') + MergeWorker.perform_in(10.minutes, 'Foo') + + 5.times do + worker_class.perform_async('Foo') + end + 3.times do + worker_class.perform_in(10.minutes, 'Foo') + end + end + end + + it 'returns the enqueued jobs plus the scheduled jobs' do + expect(coordinator.remaining).to eq(8) + end + end + + context 'when there are no jobs remaining' do + it 'returns zero' do + expect(coordinator.remaining).to be_zero + end + end + end + + describe '.exists?', :redis do + context 'when there are enqueued jobs present' do + before do + Sidekiq::Testing.disable! do + MergeWorker.perform_async('Bar') + worker_class.perform_async('Foo') + end + end + + it 'returns true if specific job exists' do + expect(coordinator.exists?('Foo')).to eq(true) + end + + it 'returns false if specific job does not exist' do + expect(coordinator.exists?('Bar')).to eq(false) + end + end + + context 'when there are scheduled jobs present' do + before do + Sidekiq::Testing.disable! do + MergeWorker.perform_in(10.minutes, 'Bar') + worker_class.perform_in(10.minutes, 'Foo') + end + end + + it 'returns true if specific job exists' do + expect(coordinator.exists?('Foo')).to eq(true) + end + + it 'returns false if specific job does not exist' do + expect(coordinator.exists?('Bar')).to eq(false) + end + end + end + + describe '.dead_jobs?' do + let(:queue) do + [ + double(args: ['Foo', [10, 20]], klass: worker_class.name), + double(args: ['Bar'], klass: 'MergeWorker') + ] + end + + context 'when there are dead jobs present' do + before do + allow(Sidekiq::DeadSet).to receive(:new).and_return(queue) + end + + it 'returns true if specific job exists' do + expect(coordinator.dead_jobs?('Foo')).to eq(true) + end + + it 'returns false if specific job does not exist' do + expect(coordinator.dead_jobs?('Bar')).to eq(false) + end + end + end + + describe '.retrying_jobs?' do + let(:queue) do + [ + double(args: ['Foo', [10, 20]], klass: worker_class.name), + double(args: ['Bar'], klass: 'MergeWorker') + ] + end + + context 'when there are dead jobs present' do + before do + allow(Sidekiq::RetrySet).to receive(:new).and_return(queue) + end + + it 'returns true if specific job exists' do + expect(coordinator.retrying_jobs?('Foo')).to eq(true) + end + + it 'returns false if specific job does not exist' do + expect(coordinator.retrying_jobs?('Bar')).to eq(false) + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/link_lfs_objects_projects_spec.rb b/spec/lib/gitlab/background_migration/link_lfs_objects_projects_spec.rb index b7cf101dd8a..64e8afedf52 100644 --- a/spec/lib/gitlab/background_migration/link_lfs_objects_projects_spec.rb +++ b/spec/lib/gitlab/background_migration/link_lfs_objects_projects_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::LinkLfsObjectsProjects, :migration, schema: 2020_03_10_075115 do +RSpec.describe Gitlab::BackgroundMigration::LinkLfsObjectsProjects, :migration, schema: 20181228175414 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:fork_networks) { table(:fork_networks) } diff --git a/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb b/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb index c58b2d609e9..4287d6723cf 100644 --- a/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::MigrateFingerprintSha256WithinKeys, schema: 20200106071113 do +RSpec.describe Gitlab::BackgroundMigration::MigrateFingerprintSha256WithinKeys, schema: 20181228175414 do subject(:fingerprint_migrator) { described_class.new } let(:key_table) { table(:keys) } diff --git a/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb b/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb deleted file mode 100644 index f2cd2acd4f3..00000000000 --- a/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb +++ /dev/null @@ -1,327 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, schema: 20200130145430 do - let(:services) { table(:services) } - - before do - # we need to define the classes due to encryption - issue_tracker_data = Class.new(ApplicationRecord) do - self.table_name = 'issue_tracker_data' - - def self.encryption_options - { - key: Settings.attr_encrypted_db_key_base_32, - encode: true, - mode: :per_attribute_iv, - algorithm: 'aes-256-gcm' - } - end - - attr_encrypted :project_url, encryption_options - attr_encrypted :issues_url, encryption_options - attr_encrypted :new_issue_url, encryption_options - end - - jira_tracker_data = Class.new(ApplicationRecord) do - self.table_name = 'jira_tracker_data' - - def self.encryption_options - { - key: Settings.attr_encrypted_db_key_base_32, - encode: true, - mode: :per_attribute_iv, - algorithm: 'aes-256-gcm' - } - end - - attr_encrypted :url, encryption_options - attr_encrypted :api_url, encryption_options - attr_encrypted :username, encryption_options - attr_encrypted :password, encryption_options - end - - stub_const('IssueTrackerData', issue_tracker_data) - stub_const('JiraTrackerData', jira_tracker_data) - end - - let(:url) { 'http://base-url.tracker.com' } - let(:new_issue_url) { 'http://base-url.tracker.com/new_issue' } - let(:issues_url) { 'http://base-url.tracker.com/issues' } - let(:api_url) { 'http://api.tracker.com' } - let(:password) { 'passw1234' } - let(:username) { 'user9' } - let(:title) { 'Issue tracker' } - let(:description) { 'Issue tracker description' } - - let(:jira_properties) do - { - 'api_url' => api_url, - 'jira_issue_transition_id' => '5', - 'password' => password, - 'url' => url, - 'username' => username, - 'title' => title, - 'description' => description, - 'other_field' => 'something' - } - end - - let(:tracker_properties) do - { - 'project_url' => url, - 'new_issue_url' => new_issue_url, - 'issues_url' => issues_url, - 'title' => title, - 'description' => description, - 'other_field' => 'something' - } - end - - let(:tracker_properties_no_url) do - { - 'new_issue_url' => new_issue_url, - 'issues_url' => issues_url, - 'title' => title, - 'description' => description - } - end - - subject { described_class.new.perform(1, 100) } - - shared_examples 'handle properties' do - it 'does not clear the properties' do - expect { subject }.not_to change { service.reload.properties} - end - end - - context 'with Jira service' do - let!(:service) do - services.create!(id: 10, type: 'JiraService', title: nil, properties: jira_properties.to_json, category: 'issue_tracker') - end - - it_behaves_like 'handle properties' - - it 'migrates data' do - expect { subject }.to change { JiraTrackerData.count }.by(1) - - service.reload - data = JiraTrackerData.find_by(service_id: service.id) - - expect(data.url).to eq(url) - expect(data.api_url).to eq(api_url) - expect(data.username).to eq(username) - expect(data.password).to eq(password) - expect(service.title).to eq(title) - expect(service.description).to eq(description) - end - end - - context 'with bugzilla service' do - let!(:service) do - services.create!(id: 11, type: 'BugzillaService', title: nil, properties: tracker_properties.to_json, category: 'issue_tracker') - end - - it_behaves_like 'handle properties' - - it 'migrates data' do - expect { subject }.to change { IssueTrackerData.count }.by(1) - - service.reload - data = IssueTrackerData.find_by(service_id: service.id) - - expect(data.project_url).to eq(url) - expect(data.issues_url).to eq(issues_url) - expect(data.new_issue_url).to eq(new_issue_url) - expect(service.title).to eq(title) - expect(service.description).to eq(description) - end - end - - context 'with youtrack service' do - let!(:service) do - services.create!(id: 12, type: 'YoutrackService', title: nil, properties: tracker_properties_no_url.to_json, category: 'issue_tracker') - end - - it_behaves_like 'handle properties' - - it 'migrates data' do - expect { subject }.to change { IssueTrackerData.count }.by(1) - - service.reload - data = IssueTrackerData.find_by(service_id: service.id) - - expect(data.project_url).to be_nil - expect(data.issues_url).to eq(issues_url) - expect(data.new_issue_url).to eq(new_issue_url) - expect(service.title).to eq(title) - expect(service.description).to eq(description) - end - end - - context 'with gitlab service with no properties' do - let!(:service) do - services.create!(id: 13, type: 'GitlabIssueTrackerService', title: nil, properties: {}, category: 'issue_tracker') - end - - it_behaves_like 'handle properties' - - it 'does not migrate data' do - expect { subject }.not_to change { IssueTrackerData.count } - end - end - - context 'with redmine service already with data fields' do - let!(:service) do - services.create!(id: 14, type: 'RedmineService', title: nil, properties: tracker_properties_no_url.to_json, category: 'issue_tracker').tap do |service| - IssueTrackerData.create!(service_id: service.id, project_url: url, new_issue_url: new_issue_url, issues_url: issues_url) - end - end - - it_behaves_like 'handle properties' - - it 'does not create new data fields record' do - expect { subject }.not_to change { IssueTrackerData.count } - end - end - - context 'with custom issue tracker which has data fields record inconsistent with properties field' do - let!(:service) do - services.create!(id: 15, type: 'CustomIssueTrackerService', title: 'Existing title', properties: jira_properties.to_json, category: 'issue_tracker').tap do |service| - IssueTrackerData.create!(service_id: service.id, project_url: 'http://other_url', new_issue_url: 'http://other_url/new_issue', issues_url: 'http://other_url/issues') - end - end - - it_behaves_like 'handle properties' - - it 'does not update the data fields record' do - expect { subject }.not_to change { IssueTrackerData.count } - - service.reload - data = IssueTrackerData.find_by(service_id: service.id) - - expect(data.project_url).to eq('http://other_url') - expect(data.issues_url).to eq('http://other_url/issues') - expect(data.new_issue_url).to eq('http://other_url/new_issue') - expect(service.title).to eq('Existing title') - end - end - - context 'with Jira service which has data fields record inconsistent with properties field' do - let!(:service) do - services.create!(id: 16, type: 'CustomIssueTrackerService', description: 'Existing description', properties: jira_properties.to_json, category: 'issue_tracker').tap do |service| - JiraTrackerData.create!(service_id: service.id, url: 'http://other_jira_url') - end - end - - it_behaves_like 'handle properties' - - it 'does not update the data fields record' do - expect { subject }.not_to change { JiraTrackerData.count } - - service.reload - data = JiraTrackerData.find_by(service_id: service.id) - - expect(data.url).to eq('http://other_jira_url') - expect(data.password).to be_nil - expect(data.username).to be_nil - expect(data.api_url).to be_nil - expect(service.description).to eq('Existing description') - end - end - - context 'non issue tracker service' do - let!(:service) do - services.create!(id: 17, title: nil, description: nil, type: 'OtherService', properties: tracker_properties.to_json) - end - - it_behaves_like 'handle properties' - - it 'does not migrate any data' do - expect { subject }.not_to change { IssueTrackerData.count } - - service.reload - expect(service.title).to be_nil - expect(service.description).to be_nil - end - end - - context 'Jira service with empty properties' do - let!(:service) do - services.create!(id: 18, type: 'JiraService', properties: '', category: 'issue_tracker') - end - - it_behaves_like 'handle properties' - - it 'does not migrate any data' do - expect { subject }.not_to change { JiraTrackerData.count } - end - end - - context 'Jira service with nil properties' do - let!(:service) do - services.create!(id: 18, type: 'JiraService', properties: nil, category: 'issue_tracker') - end - - it_behaves_like 'handle properties' - - it 'does not migrate any data' do - expect { subject }.not_to change { JiraTrackerData.count } - end - end - - context 'Jira service with invalid properties' do - let!(:service) do - services.create!(id: 18, type: 'JiraService', properties: 'invalid data', category: 'issue_tracker') - end - - it_behaves_like 'handle properties' - - it 'does not migrate any data' do - expect { subject }.not_to change { JiraTrackerData.count } - end - end - - context 'with Jira service with invalid properties, valid Jira service and valid bugzilla service' do - let!(:jira_integration_invalid) do - services.create!(id: 19, title: 'invalid - title', description: 'invalid - description', type: 'JiraService', properties: 'invalid data', category: 'issue_tracker') - end - - let!(:jira_integration_valid) do - services.create!(id: 20, type: 'JiraService', properties: jira_properties.to_json, category: 'issue_tracker') - end - - let!(:bugzilla_integration_valid) do - services.create!(id: 11, type: 'BugzillaService', title: nil, properties: tracker_properties.to_json, category: 'issue_tracker') - end - - it 'migrates data for the valid service' do - subject - - jira_integration_invalid.reload - expect(JiraTrackerData.find_by(service_id: jira_integration_invalid.id)).to be_nil - expect(jira_integration_invalid.title).to eq('invalid - title') - expect(jira_integration_invalid.description).to eq('invalid - description') - expect(jira_integration_invalid.properties).to eq('invalid data') - - jira_integration_valid.reload - data = JiraTrackerData.find_by(service_id: jira_integration_valid.id) - - expect(data.url).to eq(url) - expect(data.api_url).to eq(api_url) - expect(data.username).to eq(username) - expect(data.password).to eq(password) - expect(jira_integration_valid.title).to eq(title) - expect(jira_integration_valid.description).to eq(description) - - bugzilla_integration_valid.reload - data = IssueTrackerData.find_by(service_id: bugzilla_integration_valid.id) - - expect(data.project_url).to eq(url) - expect(data.issues_url).to eq(issues_url) - expect(data.new_issue_url).to eq(new_issue_url) - expect(bugzilla_integration_valid.title).to eq(title) - expect(bugzilla_integration_valid.description).to eq(description) - end - end -end diff --git a/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb b/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb index 91e8dcdf880..31b6ee0c7cd 100644 --- a/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers do +RSpec.describe Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers, schema: 20211012134316 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:users) { table(:users) } diff --git a/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb b/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb index 9eda51f6ec4..ab183d01357 100644 --- a/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' require 'webauthn/u2f_migrator' -RSpec.describe Gitlab::BackgroundMigration::MigrateU2fWebauthn, :migration, schema: 20200925125321 do +RSpec.describe Gitlab::BackgroundMigration::MigrateU2fWebauthn, :migration, schema: 20181228175414 do let(:users) { table(:users) } let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) } diff --git a/spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb b/spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb deleted file mode 100644 index d90a5d30954..00000000000 --- a/spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::MigrateUsersBioToUserDetails, :migration, schema: 20200323074147 do - let(:users) { table(:users) } - - let(:user_details) do - klass = table(:user_details) - klass.primary_key = :user_id - klass - end - - let!(:user_needs_migration) { users.create!(name: 'user1', email: 'test1@test.com', projects_limit: 1, bio: 'bio') } - let!(:user_needs_no_migration) { users.create!(name: 'user2', email: 'test2@test.com', projects_limit: 1) } - let!(:user_also_needs_no_migration) { users.create!(name: 'user3', email: 'test3@test.com', projects_limit: 1, bio: '') } - let!(:user_with_long_bio) { users.create!(name: 'user4', email: 'test4@test.com', projects_limit: 1, bio: 'a' * 256) } # 255 is the max - - let!(:user_already_has_details) { users.create!(name: 'user5', email: 'test5@test.com', projects_limit: 1, bio: 'my bio') } - let!(:existing_user_details) { user_details.find_or_create_by!(user_id: user_already_has_details.id).update!(bio: 'my bio') } - - # unlikely scenario since we have triggers - let!(:user_has_different_details) { users.create!(name: 'user6', email: 'test6@test.com', projects_limit: 1, bio: 'different') } - let!(:different_existing_user_details) { user_details.find_or_create_by!(user_id: user_has_different_details.id).update!(bio: 'bio') } - - let(:user_ids) do - [ - user_needs_migration, - user_needs_no_migration, - user_also_needs_no_migration, - user_with_long_bio, - user_already_has_details, - user_has_different_details - ].map(&:id) - end - - subject { described_class.new.perform(user_ids.min, user_ids.max) } - - it 'migrates all relevant records' do - subject - - all_user_details = user_details.all - expect(all_user_details.size).to eq(4) - end - - it 'migrates `bio`' do - subject - - user_detail = user_details.find_by!(user_id: user_needs_migration.id) - - expect(user_detail.bio).to eq('bio') - end - - it 'migrates long `bio`' do - subject - - user_detail = user_details.find_by!(user_id: user_with_long_bio.id) - - expect(user_detail.bio).to eq('a' * 255) - end - - it 'does not change existing user detail' do - expect { subject }.not_to change { user_details.find_by!(user_id: user_already_has_details.id).attributes } - end - - it 'changes existing user detail when the columns are different' do - expect { subject }.to change { user_details.find_by!(user_id: user_has_different_details.id).bio }.from('bio').to('different') - end - - it 'does not migrate record' do - subject - - user_detail = user_details.find_by(user_id: user_needs_no_migration.id) - - expect(user_detail).to be_nil - end - - it 'does not migrate empty bio' do - subject - - user_detail = user_details.find_by(user_id: user_also_needs_no_migration.id) - - expect(user_detail).to be_nil - end -end diff --git a/spec/lib/gitlab/background_migration/populate_canonical_emails_spec.rb b/spec/lib/gitlab/background_migration/populate_canonical_emails_spec.rb index 36000dc3ffd..944ee98ed4a 100644 --- a/spec/lib/gitlab/background_migration/populate_canonical_emails_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_canonical_emails_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateCanonicalEmails, :migration, schema: 20200312053852 do +RSpec.describe Gitlab::BackgroundMigration::PopulateCanonicalEmails, :migration, schema: 20181228175414 do let(:migration) { described_class.new } let_it_be(:users_table) { table(:users) } diff --git a/spec/lib/gitlab/background_migration/populate_dismissed_state_for_vulnerabilities_spec.rb b/spec/lib/gitlab/background_migration/populate_dismissed_state_for_vulnerabilities_spec.rb index bc55f240a58..dc8c8c75b83 100644 --- a/spec/lib/gitlab/background_migration/populate_dismissed_state_for_vulnerabilities_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_dismissed_state_for_vulnerabilities_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ::Gitlab::BackgroundMigration::PopulateDismissedStateForVulnerabilities, schema: 2020_11_30_103926 do +RSpec.describe ::Gitlab::BackgroundMigration::PopulateDismissedStateForVulnerabilities, schema: 20181228175414 do let(:users) { table(:users) } let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb b/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb index 07b1d99d333..25006e663ab 100644 --- a/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateFindingUuidForVulnerabilityFeedback, schema: 20201211090634 do +RSpec.describe Gitlab::BackgroundMigration::PopulateFindingUuidForVulnerabilityFeedback, schema: 20181228175414 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:users) { table(:users) } diff --git a/spec/lib/gitlab/background_migration/populate_has_vulnerabilities_spec.rb b/spec/lib/gitlab/background_migration/populate_has_vulnerabilities_spec.rb index c6385340ca3..6722321d5f7 100644 --- a/spec/lib/gitlab/background_migration/populate_has_vulnerabilities_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_has_vulnerabilities_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateHasVulnerabilities, schema: 20201103192526 do +RSpec.describe Gitlab::BackgroundMigration::PopulateHasVulnerabilities, schema: 20181228175414 do let(:users) { table(:users) } let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/lib/gitlab/background_migration/populate_issue_email_participants_spec.rb b/spec/lib/gitlab/background_migration/populate_issue_email_participants_spec.rb index f724b007e01..a03a11489b5 100644 --- a/spec/lib/gitlab/background_migration/populate_issue_email_participants_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_issue_email_participants_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateIssueEmailParticipants, schema: 20201128210234 do +RSpec.describe Gitlab::BackgroundMigration::PopulateIssueEmailParticipants, schema: 20181228175414 do let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } let!(:project) { table(:projects).create!(id: 1, namespace_id: namespace.id) } let!(:issue1) { table(:issues).create!(id: 1, project_id: project.id, service_desk_reply_to: "a@gitlab.com") } diff --git a/spec/lib/gitlab/background_migration/populate_missing_vulnerability_dismissal_information_spec.rb b/spec/lib/gitlab/background_migration/populate_missing_vulnerability_dismissal_information_spec.rb index 44c5f3d1381..1c987d3876f 100644 --- a/spec/lib/gitlab/background_migration/populate_missing_vulnerability_dismissal_information_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_missing_vulnerability_dismissal_information_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateMissingVulnerabilityDismissalInformation, schema: 20201028160832 do +RSpec.describe Gitlab::BackgroundMigration::PopulateMissingVulnerabilityDismissalInformation, schema: 20181228175414 do let(:users) { table(:users) } let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/lib/gitlab/background_migration/populate_personal_snippet_statistics_spec.rb b/spec/lib/gitlab/background_migration/populate_personal_snippet_statistics_spec.rb index e746451b1b9..f9628849dbf 100644 --- a/spec/lib/gitlab/background_migration/populate_personal_snippet_statistics_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_personal_snippet_statistics_spec.rb @@ -111,11 +111,11 @@ RSpec.describe Gitlab::BackgroundMigration::PopulatePersonalSnippetStatistics do if with_repo allow(snippet).to receive(:disk_path).and_return(disk_path(snippet)) + raw_repository(snippet).create_repository + TestEnv.copy_repo(snippet, bare_repo: TestEnv.factory_repo_path_bare, refs: TestEnv::BRANCH_SHA) - - raw_repository(snippet).create_repository end end end diff --git a/spec/lib/gitlab/background_migration/populate_project_snippet_statistics_spec.rb b/spec/lib/gitlab/background_migration/populate_project_snippet_statistics_spec.rb index 897f5e81372..7884e0d97c0 100644 --- a/spec/lib/gitlab/background_migration/populate_project_snippet_statistics_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_project_snippet_statistics_spec.rb @@ -183,11 +183,11 @@ RSpec.describe Gitlab::BackgroundMigration::PopulateProjectSnippetStatistics do if with_repo allow(snippet).to receive(:disk_path).and_return(disk_path(snippet)) + raw_repository(snippet).create_repository + TestEnv.copy_repo(snippet, bare_repo: TestEnv.factory_repo_path_bare, refs: TestEnv::BRANCH_SHA) - - raw_repository(snippet).create_repository end end end diff --git a/spec/lib/gitlab/background_migration/populate_user_highest_roles_table_spec.rb b/spec/lib/gitlab/background_migration/populate_user_highest_roles_table_spec.rb deleted file mode 100644 index b3cacc60cdc..00000000000 --- a/spec/lib/gitlab/background_migration/populate_user_highest_roles_table_spec.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::PopulateUserHighestRolesTable, schema: 20200311130802 do - let(:members) { table(:members) } - let(:users) { table(:users) } - let(:user_highest_roles) { table(:user_highest_roles) } - - def create_user(id, params = {}) - user_params = { - id: id, - state: 'active', - user_type: nil, - bot_type: nil, - ghost: nil, - email: "user#{id}@example.com", - projects_limit: 0 - }.merge(params) - - users.create!(user_params) - end - - def create_member(id, access_level, params = {}) - params = { - user_id: id, - access_level: access_level, - source_id: 1, - source_type: 'Group', - notification_level: 0 - }.merge(params) - - members.create!(params) - end - - before do - create_user(1) - create_user(2, state: 'blocked') - create_user(3, user_type: 2) - create_user(4) - create_user(5, bot_type: 1) - create_user(6, ghost: true) - create_user(7, ghost: false) - create_user(8) - - create_member(1, 40) - create_member(7, 30) - create_member(8, 20, requested_at: Time.current) - - user_highest_roles.create!(user_id: 1, highest_access_level: 50) - end - - describe '#perform' do - it 'creates user_highest_roles rows according to users', :aggregate_failures do - expect { subject.perform(1, 8) }.to change(UserHighestRole, :count).from(1).to(4) - - created_or_updated_rows = [ - { 'user_id' => 1, 'highest_access_level' => 40 }, - { 'user_id' => 4, 'highest_access_level' => nil }, - { 'user_id' => 7, 'highest_access_level' => 30 }, - { 'user_id' => 8, 'highest_access_level' => nil } - ] - - rows = user_highest_roles.order(:user_id).map do |row| - row.attributes.slice('user_id', 'highest_access_level') - end - - expect(rows).to match_array(created_or_updated_rows) - end - end -end 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 new file mode 100644 index 00000000000..24259b06469 --- /dev/null +++ b/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::ProjectNamespaces::BackfillProjectNamespaces, :migration do + include MigrationsHelpers + + context 'when migrating data', :aggregate_failures do + let(:projects) { table(:projects) } + let(:namespaces) { table(:namespaces) } + + let(:parent_group1) { namespaces.create!(name: 'parent_group1', path: 'parent_group1', visibility_level: 20, type: 'Group') } + let(:parent_group2) { namespaces.create!(name: 'test1', path: 'test1', runners_token: 'my-token1', project_creation_level: 1, visibility_level: 20, type: 'Group') } + + let(:parent_group1_project) { projects.create!(name: 'parent_group1_project', path: 'parent_group1_project', namespace_id: parent_group1.id, visibility_level: 20) } + let(:parent_group2_project) { projects.create!(name: 'parent_group2_project', path: 'parent_group2_project', namespace_id: parent_group2.id, visibility_level: 20) } + + let(:child_nodes_count) { 2 } + let(:tree_depth) { 3 } + + let(:backfilled_namespace) { nil } + + before do + BackfillProjectNamespaces::TreeGenerator.new(namespaces, projects, [parent_group1, parent_group2], child_nodes_count, tree_depth).build_tree + end + + describe '#up' do + shared_examples 'back-fill project namespaces' do + it 'back-fills all project namespaces' do + start_id = ::Project.minimum(:id) + end_id = ::Project.maximum(:id) + projects_count = ::Project.count + batches_count = (projects_count / described_class::BATCH_SIZE.to_f).ceil + project_namespaces_count = ::Namespace.where(type: 'Project').count + migration = described_class.new + + expect(projects_count).not_to eq(project_namespaces_count) + expect(migration).to receive(:batch_insert_namespaces).exactly(batches_count).and_call_original + expect(migration).to receive(:batch_update_projects).exactly(batches_count).and_call_original + expect(migration).to receive(:batch_update_project_namespaces_traversal_ids).exactly(batches_count).and_call_original + + expect { migration.perform(start_id, end_id, nil, 'up') }.to change(Namespace.where(type: 'Project'), :count) + + expect(projects_count).to eq(::Namespace.where(type: 'Project').count) + check_projects_in_sync_with(Namespace.where(type: 'Project')) + end + + context 'when passing specific group as parameter' do + let(:backfilled_namespace) { parent_group1 } + + it 'back-fills project namespaces for the specified group hierarchy' do + backfilled_namespace_projects = base_ancestor(backfilled_namespace).first.all_projects + start_id = backfilled_namespace_projects.minimum(:id) + end_id = backfilled_namespace_projects.maximum(:id) + group_projects_count = backfilled_namespace_projects.count + batches_count = (group_projects_count / described_class::BATCH_SIZE.to_f).ceil + project_namespaces_in_hierarchy = project_namespaces_in_hierarchy(base_ancestor(backfilled_namespace)) + + migration = described_class.new + + expect(project_namespaces_in_hierarchy.count).to eq(0) + expect(migration).to receive(:batch_insert_namespaces).exactly(batches_count).and_call_original + expect(migration).to receive(:batch_update_projects).exactly(batches_count).and_call_original + expect(migration).to receive(:batch_update_project_namespaces_traversal_ids).exactly(batches_count).and_call_original + + expect(group_projects_count).to eq(14) + expect(project_namespaces_in_hierarchy.count).to eq(0) + + migration.perform(start_id, end_id, backfilled_namespace.id, 'up') + + expect(project_namespaces_in_hierarchy.count).to eq(14) + check_projects_in_sync_with(project_namespaces_in_hierarchy) + end + end + + context 'when projects already have project namespaces' do + before do + hierarchy1_projects = base_ancestor(parent_group1).first.all_projects + start_id = hierarchy1_projects.minimum(:id) + end_id = hierarchy1_projects.maximum(:id) + + described_class.new.perform(start_id, end_id, parent_group1.id, 'up') + end + + it 'does not duplicate project namespaces' do + # check there are already some project namespaces but not for all + projects_count = ::Project.count + start_id = ::Project.minimum(:id) + end_id = ::Project.maximum(:id) + batches_count = (projects_count / described_class::BATCH_SIZE.to_f).ceil + project_namespaces = ::Namespace.where(type: 'Project') + migration = described_class.new + + expect(project_namespaces_in_hierarchy(base_ancestor(parent_group1)).count).to be >= 14 + expect(project_namespaces_in_hierarchy(base_ancestor(parent_group2)).count).to eq(0) + expect(projects_count).not_to eq(project_namespaces.count) + + # run migration again to test we do not generate extra project namespaces + expect(migration).to receive(:batch_insert_namespaces).exactly(batches_count).and_call_original + expect(migration).to receive(:batch_update_projects).exactly(batches_count).and_call_original + expect(migration).to receive(:batch_update_project_namespaces_traversal_ids).exactly(batches_count).and_call_original + + expect { migration.perform(start_id, end_id, nil, 'up') }.to change(project_namespaces, :count).by(14) + + expect(projects_count).to eq(project_namespaces.count) + end + end + end + + it 'checks no project namespaces exist in the defined hierarchies' do + hierarchy1_project_namespaces = project_namespaces_in_hierarchy(base_ancestor(parent_group1)) + hierarchy2_project_namespaces = project_namespaces_in_hierarchy(base_ancestor(parent_group2)) + hierarchy1_projects_count = base_ancestor(parent_group1).first.all_projects.count + hierarchy2_projects_count = base_ancestor(parent_group2).first.all_projects.count + + expect(hierarchy1_project_namespaces).to be_empty + expect(hierarchy2_project_namespaces).to be_empty + expect(hierarchy1_projects_count).to eq(14) + expect(hierarchy2_projects_count).to eq(14) + end + + context 'back-fill project namespaces in a single batch' do + it_behaves_like 'back-fill project namespaces' + end + + context 'back-fill project namespaces in batches' do + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + end + + it_behaves_like 'back-fill project namespaces' + end + end + + describe '#down' do + before do + start_id = ::Project.minimum(:id) + end_id = ::Project.maximum(:id) + # back-fill first + described_class.new.perform(start_id, end_id, nil, 'up') + end + + shared_examples 'cleanup project namespaces' do + it 'removes project namespaces' do + projects_count = ::Project.count + start_id = ::Project.minimum(:id) + end_id = ::Project.maximum(:id) + migration = described_class.new + batches_count = (projects_count / described_class::BATCH_SIZE.to_f).ceil + + expect(projects_count).to be > 0 + expect(projects_count).to eq(::Namespace.where(type: 'Project').count) + + expect(migration).to receive(:nullify_project_namespaces_in_projects).exactly(batches_count).and_call_original + expect(migration).to receive(:delete_project_namespace_records).exactly(batches_count).and_call_original + + migration.perform(start_id, end_id, nil, 'down') + + expect(::Project.count).to be > 0 + expect(::Namespace.where(type: 'Project').count).to eq(0) + end + + context 'when passing specific group as parameter' do + let(:backfilled_namespace) { parent_group1 } + + it 'removes project namespaces only for the specific group hierarchy' do + backfilled_namespace_projects = base_ancestor(backfilled_namespace).first.all_projects + start_id = backfilled_namespace_projects.minimum(:id) + end_id = backfilled_namespace_projects.maximum(:id) + group_projects_count = backfilled_namespace_projects.count + batches_count = (group_projects_count / described_class::BATCH_SIZE.to_f).ceil + project_namespaces_in_hierarchy = project_namespaces_in_hierarchy(base_ancestor(backfilled_namespace)) + migration = described_class.new + + expect(project_namespaces_in_hierarchy.count).to eq(14) + expect(migration).to receive(:nullify_project_namespaces_in_projects).exactly(batches_count).and_call_original + expect(migration).to receive(:delete_project_namespace_records).exactly(batches_count).and_call_original + + migration.perform(start_id, end_id, backfilled_namespace.id, 'down') + + expect(::Namespace.where(type: 'Project').count).to be > 0 + expect(project_namespaces_in_hierarchy.count).to eq(0) + end + end + end + + context 'cleanup project namespaces in a single batch' do + it_behaves_like 'cleanup project namespaces' + end + + context 'cleanup project namespaces in batches' do + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + end + + it_behaves_like 'cleanup project namespaces' + end + end + end + + def base_ancestor(ancestor) + ::Namespace.where(id: ancestor.id) + end + + def project_namespaces_in_hierarchy(base_node) + Gitlab::ObjectHierarchy.new(base_node).base_and_descendants.where(type: 'Project') + end + + def check_projects_in_sync_with(namespaces) + project_namespaces_attrs = namespaces.order(:id).pluck(:id, :name, :path, :parent_id, :visibility_level, :shared_runners_enabled) + corresponding_projects_attrs = Project.where(project_namespace_id: project_namespaces_attrs.map(&:first)) + .order(:project_namespace_id).pluck(:project_namespace_id, :name, :path, :namespace_id, :visibility_level, :shared_runners_enabled) + + expect(project_namespaces_attrs).to eq(corresponding_projects_attrs) + end +end + +module BackfillProjectNamespaces + class TreeGenerator + def initialize(namespaces, projects, parent_nodes, child_nodes_count, tree_depth) + parent_nodes_ids = parent_nodes.map(&:id) + + @namespaces = namespaces + @projects = projects + @subgroups_depth = tree_depth + @resource_count = child_nodes_count + @all_groups = [parent_nodes_ids] + end + + def build_tree + (1..@subgroups_depth).each do |level| + parent_level = level - 1 + current_level = level + parent_groups = @all_groups[parent_level] + + parent_groups.each do |parent_id| + @resource_count.times do |i| + group_path = "child#{i}_level#{level}" + project_path = "project#{i}_level#{level}" + sub_group = @namespaces.create!(name: group_path, path: group_path, parent_id: parent_id, visibility_level: 20, type: 'Group') + @projects.create!(name: project_path, path: project_path, namespace_id: sub_group.id, visibility_level: 20) + + track_group_id(current_level, sub_group.id) + end + end + end + end + + def track_group_id(depth_level, group_id) + @all_groups[depth_level] ||= [] + @all_groups[depth_level] << group_id + end + end +end diff --git a/spec/lib/gitlab/background_migration/recalculate_project_authorizations_with_min_max_user_id_spec.rb b/spec/lib/gitlab/background_migration/recalculate_project_authorizations_with_min_max_user_id_spec.rb index c1ba1607b89..1830a7fc099 100644 --- a/spec/lib/gitlab/background_migration/recalculate_project_authorizations_with_min_max_user_id_spec.rb +++ b/spec/lib/gitlab/background_migration/recalculate_project_authorizations_with_min_max_user_id_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::RecalculateProjectAuthorizationsWithMinMaxUserId, schema: 20200204113224 do +RSpec.describe Gitlab::BackgroundMigration::RecalculateProjectAuthorizationsWithMinMaxUserId, schema: 20181228175414 do let(:users_table) { table(:users) } let(:min) { 1 } let(:max) { 5 } diff --git a/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb index 30908145782..4cdb56d3d3b 100644 --- a/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb +++ b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid, schema: 20201110110454 do +RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid, schema: 20181228175414 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_duplicate_services_spec.rb b/spec/lib/gitlab/background_migration/remove_duplicate_services_spec.rb index 391b27b28e6..afcdaaf1cb8 100644 --- a/spec/lib/gitlab/background_migration/remove_duplicate_services_spec.rb +++ b/spec/lib/gitlab/background_migration/remove_duplicate_services_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateServices, :migration, schema: 20201207165956 do +RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateServices, :migration, schema: 20181228175414 do let_it_be(:users) { table(:users) } let_it_be(:namespaces) { table(:namespaces) } let_it_be(:projects) { table(:projects) } 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 47e1d4620cd..7214225c32c 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 @@ -5,9 +5,9 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindin let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let(:users) { table(:users) } let(:user) { create_user! } - let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) } + let(:project) { table(:projects).create!(id: 14219619, namespace_id: namespace.id) } let(:scanners) { table(:vulnerability_scanners) } - let!(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } + let!(:scanner1) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } let!(:scanner2) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } let!(:scanner3) { scanners.create!(project_id: project.id, external_id: 'test 3', name: 'test scanner 3') } let!(:unrelated_scanner) { scanners.create!(project_id: project.id, external_id: 'unreleated_scanner', name: 'unrelated scanner') } @@ -16,43 +16,68 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindin let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } let(:vulnerability_identifier) do vulnerability_identifiers.create!( + id: 1244459, project_id: project.id, external_type: 'vulnerability-identifier', external_id: 'vulnerability-identifier', - fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a', + fingerprint: '0a203e8cd5260a1948edbedc76c7cb91ad6a2e45', name: 'vulnerability identifier') end - let!(:first_finding) do + let!(:vulnerability_for_first_duplicate) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:first_finding_duplicate) do create_finding!( - uuid: "test1", - vulnerability_id: nil, + id: 5606961, + uuid: "bd95c085-71aa-51d7-9bb6-08ae669c262e", + vulnerability_id: vulnerability_for_first_duplicate.id, report_type: 0, - location_fingerprint: '2bda3014914481791847d8eca38d1a8d13b6ad76', + location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75', primary_identifier_id: vulnerability_identifier.id, - scanner_id: scanner.id, + scanner_id: scanner1.id, project_id: project.id ) end - let!(:first_duplicate) do + let!(:vulnerability_for_second_duplicate) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:second_finding_duplicate) do create_finding!( - uuid: "test2", - vulnerability_id: nil, + id: 8765432, + uuid: "5b714f58-1176-5b26-8fd5-e11dfcb031b5", + vulnerability_id: vulnerability_for_second_duplicate.id, report_type: 0, - location_fingerprint: '2bda3014914481791847d8eca38d1a8d13b6ad76', + location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75', primary_identifier_id: vulnerability_identifier.id, scanner_id: scanner2.id, project_id: project.id ) end - let!(:second_duplicate) do + let!(:vulnerability_for_third_duplicate) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:third_finding_duplicate) do create_finding!( - uuid: "test3", - vulnerability_id: nil, + id: 8832995, + uuid: "cfe435fa-b25b-5199-a56d-7b007cc9e2d4", + vulnerability_id: vulnerability_for_third_duplicate.id, report_type: 0, - location_fingerprint: '2bda3014914481791847d8eca38d1a8d13b6ad76', + location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75', primary_identifier_id: vulnerability_identifier.id, scanner_id: scanner3.id, project_id: project.id @@ -61,6 +86,7 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindin let!(:unrelated_finding) do create_finding!( + id: 9999999, uuid: "unreleated_finding", vulnerability_id: nil, report_type: 1, @@ -71,7 +97,7 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindin ) end - subject { described_class.new.perform(first_finding.id, unrelated_finding.id) } + subject { described_class.new.perform(first_finding_duplicate.id, unrelated_finding.id) } before do stub_const("#{described_class}::DELETE_BATCH_SIZE", 1) @@ -82,7 +108,15 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindin expect { subject }.to change { vulnerability_findings.count }.from(4).to(2) - expect(vulnerability_findings.pluck(:id)).to eq([second_duplicate.id, unrelated_finding.id]) + expect(vulnerability_findings.pluck(:id)).to match_array([third_finding_duplicate.id, unrelated_finding.id]) + end + + it "removes vulnerabilites without findings" do + expect(vulnerabilities.count).to eq(3) + + expect { subject }.to change { vulnerabilities.count }.from(3).to(1) + + expect(vulnerabilities.pluck(:id)).to match_array([vulnerability_for_third_duplicate.id]) end private @@ -100,11 +134,12 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindin # rubocop:disable Metrics/ParameterLists def create_finding!( + id: nil, vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, name: "test", severity: 7, confidence: 7, report_type: 0, project_fingerprint: '123qweasdzxc', location_fingerprint: 'test', metadata_version: 'test', raw_metadata: 'test', uuid: 'test') - vulnerability_findings.create!( + params = { vulnerability_id: vulnerability_id, project_id: project_id, name: name, @@ -118,7 +153,9 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindin metadata_version: metadata_version, raw_metadata: raw_metadata, uuid: uuid - ) + } + params[:id] = id unless id.nil? + vulnerability_findings.create!(params) end # rubocop:enable Metrics/ParameterLists diff --git a/spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb b/spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb index 561a602fab9..6cfdbb5a14e 100644 --- a/spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb +++ b/spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::ReplaceBlockedByLinks, schema: 20201015073808 do +RSpec.describe Gitlab::BackgroundMigration::ReplaceBlockedByLinks, schema: 20181228175414 do let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') } let(:project) { table(:projects).create!(namespace_id: namespace.id, name: 'gitlab') } let(:issue1) { table(:issues).create!(project_id: project.id, title: 'a') } diff --git a/spec/lib/gitlab/background_migration/reset_shared_runners_for_transferred_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_shared_runners_for_transferred_projects_spec.rb index 68aa64a1c7d..ef90b5674f0 100644 --- a/spec/lib/gitlab/background_migration/reset_shared_runners_for_transferred_projects_spec.rb +++ b/spec/lib/gitlab/background_migration/reset_shared_runners_for_transferred_projects_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::ResetSharedRunnersForTransferredProjects, schema: 20201110161542 do +RSpec.describe Gitlab::BackgroundMigration::ResetSharedRunnersForTransferredProjects, schema: 20181228175414 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb b/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb deleted file mode 100644 index 46c919f0854..00000000000 --- a/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::SetDefaultIterationCadences, schema: 20201231133921 do - let(:namespaces) { table(:namespaces) } - let(:iterations) { table(:sprints) } - let(:iterations_cadences) { table(:iterations_cadences) } - - describe '#perform' do - context 'when no iteration cadences exists' do - let!(:group_1) { namespaces.create!(name: 'group 1', path: 'group-1') } - let!(:group_2) { namespaces.create!(name: 'group 2', path: 'group-2') } - let!(:group_3) { namespaces.create!(name: 'group 3', path: 'group-3') } - - let!(:iteration_1) { iterations.create!(group_id: group_1.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) } - let!(:iteration_2) { iterations.create!(group_id: group_3.id, iid: 1, title: 'Iteration 2', start_date: 10.days.ago, due_date: 8.days.ago) } - let!(:iteration_3) { iterations.create!(group_id: group_3.id, iid: 1, title: 'Iteration 3', start_date: 5.days.ago, due_date: 2.days.ago) } - - subject { described_class.new.perform(group_1.id, group_2.id, group_3.id, namespaces.last.id + 1) } - - before do - subject - end - - it 'creates iterations_cadence records for the requested groups' do - expect(iterations_cadences.count).to eq(2) - end - - it 'assigns the iteration cadences to the iterations correctly' do - iterations_cadence = iterations_cadences.find_by(group_id: group_1.id) - iteration_records = iterations.where(iterations_cadence_id: iterations_cadence.id) - - expect(iterations_cadence.start_date).to eq(iteration_1.start_date) - expect(iterations_cadence.last_run_date).to eq(iteration_1.start_date) - expect(iterations_cadence.title).to eq('group 1 Iterations') - expect(iteration_records.size).to eq(1) - expect(iteration_records.first.id).to eq(iteration_1.id) - - iterations_cadence = iterations_cadences.find_by(group_id: group_3.id) - iteration_records = iterations.where(iterations_cadence_id: iterations_cadence.id) - - expect(iterations_cadence.start_date).to eq(iteration_3.start_date) - expect(iterations_cadence.last_run_date).to eq(iteration_3.start_date) - expect(iterations_cadence.title).to eq('group 3 Iterations') - expect(iteration_records.size).to eq(2) - expect(iteration_records.first.id).to eq(iteration_2.id) - expect(iteration_records.second.id).to eq(iteration_3.id) - end - - it 'does not call Group class' do - expect(::Group).not_to receive(:where) - - subject - end - end - - context 'when an iteration cadence exists for a group' do - let!(:group) { namespaces.create!(name: 'group', path: 'group') } - - let!(:iterations_cadence_1) { iterations_cadences.create!(group_id: group.id, start_date: 2.days.ago, title: 'Cadence 1') } - - let!(:iteration_1) { iterations.create!(group_id: group.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) } - let!(:iteration_2) { iterations.create!(group_id: group.id, iterations_cadence_id: iterations_cadence_1.id, iid: 2, title: 'Iteration 2', start_date: 5.days.ago, due_date: 3.days.ago) } - - subject { described_class.new.perform(group.id) } - - it 'does not create a new iterations_cadence' do - expect { subject }.not_to change { iterations_cadences.count } - end - - it 'assigns iteration cadences to iterations if needed' do - subject - - expect(iteration_1.reload.iterations_cadence_id).to eq(iterations_cadence_1.id) - expect(iteration_2.reload.iterations_cadence_id).to eq(iterations_cadence_1.id) - end - end - end -end diff --git a/spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb b/spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb index f23518625e4..1fdbdf25706 100644 --- a/spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb +++ b/spec/lib/gitlab/background_migration/set_merge_request_diff_files_count_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::SetMergeRequestDiffFilesCount, schema: 20200807152315 do +RSpec.describe Gitlab::BackgroundMigration::SetMergeRequestDiffFilesCount, schema: 20181228175414 do let(:merge_request_diff_files) { table(:merge_request_diff_files) } let(:merge_request_diffs) { table(:merge_request_diffs) } let(:merge_requests) { table(:merge_requests) } diff --git a/spec/lib/gitlab/background_migration/set_null_external_diff_store_to_local_value_spec.rb b/spec/lib/gitlab/background_migration/set_null_external_diff_store_to_local_value_spec.rb deleted file mode 100644 index 6079ad2dd2a..00000000000 --- a/spec/lib/gitlab/background_migration/set_null_external_diff_store_to_local_value_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# The test setup must begin before -# 20200804041930_add_not_null_constraint_on_external_diff_store_to_merge_request_diffs.rb -# has run, or else we cannot insert a row with `NULL` `external_diff_store` to -# test against. -RSpec.describe Gitlab::BackgroundMigration::SetNullExternalDiffStoreToLocalValue, schema: 20200804035230 do - let!(:merge_request_diffs) { table(:merge_request_diffs) } - let!(:merge_requests) { table(:merge_requests) } - let!(:namespaces) { table(:namespaces) } - let!(:projects) { table(:projects) } - let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(namespace_id: namespace.id) } - let!(:merge_request) { merge_requests.create!(source_branch: 'x', target_branch: 'master', target_project_id: project.id) } - - it 'correctly migrates nil external_diff_store to 1' do - external_diff_store_1 = merge_request_diffs.create!(external_diff_store: 1, merge_request_id: merge_request.id) - external_diff_store_2 = merge_request_diffs.create!(external_diff_store: 2, merge_request_id: merge_request.id) - external_diff_store_nil = merge_request_diffs.create!(external_diff_store: nil, merge_request_id: merge_request.id) - - described_class.new.perform(external_diff_store_1.id, external_diff_store_nil.id) - - external_diff_store_1.reload - external_diff_store_2.reload - external_diff_store_nil.reload - - expect(external_diff_store_1.external_diff_store).to eq(1) # unchanged - expect(external_diff_store_2.external_diff_store).to eq(2) # unchanged - expect(external_diff_store_nil.external_diff_store).to eq(1) # nil => 1 - end -end diff --git a/spec/lib/gitlab/background_migration/set_null_package_files_file_store_to_local_value_spec.rb b/spec/lib/gitlab/background_migration/set_null_package_files_file_store_to_local_value_spec.rb deleted file mode 100644 index 40d41262fc7..00000000000 --- a/spec/lib/gitlab/background_migration/set_null_package_files_file_store_to_local_value_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# The test setup must begin before -# 20200806004742_add_not_null_constraint_on_file_store_to_package_files.rb -# has run, or else we cannot insert a row with `NULL` `file_store` to -# test against. -RSpec.describe Gitlab::BackgroundMigration::SetNullPackageFilesFileStoreToLocalValue, schema: 20200806004232 do - let!(:packages_package_files) { table(:packages_package_files) } - let!(:packages_packages) { table(:packages_packages) } - let!(:projects) { table(:projects) } - let!(:namespaces) { table(:namespaces) } - let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(namespace_id: namespace.id) } - let!(:package) { packages_packages.create!(project_id: project.id, name: 'bar', package_type: 1) } - - it 'correctly migrates nil file_store to 1' do - file_store_1 = packages_package_files.create!(file_store: 1, file_name: 'foo_1', file: 'foo_1', package_id: package.id) - file_store_2 = packages_package_files.create!(file_store: 2, file_name: 'foo_2', file: 'foo_2', package_id: package.id) - file_store_nil = packages_package_files.create!(file_store: nil, file_name: 'foo_nil', file: 'foo_nil', package_id: package.id) - - described_class.new.perform(file_store_1.id, file_store_nil.id) - - file_store_1.reload - file_store_2.reload - file_store_nil.reload - - expect(file_store_1.file_store).to eq(1) # unchanged - expect(file_store_2.file_store).to eq(2) # unchanged - expect(file_store_nil.file_store).to eq(1) # nil => 1 - end -end diff --git a/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb b/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb index f2fb2ab6b6e..841a7f306d7 100644 --- a/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb +++ b/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::StealMigrateMergeRequestDiffCommitUsers do +RSpec.describe Gitlab::BackgroundMigration::StealMigrateMergeRequestDiffCommitUsers, schema: 20211012134316 do let(:migration) { described_class.new } describe '#perform' do diff --git a/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb b/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb index 6c0a1d3a5b0..de9799c3642 100644 --- a/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb +++ b/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::UpdateExistingSubgroupToMatchVisibilityLevelOfParent, schema: 2020_01_10_121314 do +RSpec.describe Gitlab::BackgroundMigration::UpdateExistingSubgroupToMatchVisibilityLevelOfParent, schema: 20181228175414 do include MigrationHelpers::NamespacesHelpers context 'private visibility level' do diff --git a/spec/lib/gitlab/background_migration/update_existing_users_that_require_two_factor_auth_spec.rb b/spec/lib/gitlab/background_migration/update_existing_users_that_require_two_factor_auth_spec.rb index bebb398413b..33f5e38100e 100644 --- a/spec/lib/gitlab/background_migration/update_existing_users_that_require_two_factor_auth_spec.rb +++ b/spec/lib/gitlab/background_migration/update_existing_users_that_require_two_factor_auth_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::UpdateExistingUsersThatRequireTwoFactorAuth, schema: 20201030121314 do +RSpec.describe Gitlab::BackgroundMigration::UpdateExistingUsersThatRequireTwoFactorAuth, schema: 20181228175414 do include MigrationHelpers::NamespacesHelpers let(:group_with_2fa_parent) { create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE) } diff --git a/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb b/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb index 2dae4a65eeb..7af11ffa1e0 100644 --- a/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb +++ b/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb @@ -1,120 +1,8 @@ # frozen_string_literal: true require 'spec_helper' -require './db/post_migrate/20200128134110_migrate_commit_notes_mentions_to_db' -require './db/post_migrate/20200211155539_migrate_merge_request_mentions_to_db' - -RSpec.describe Gitlab::BackgroundMigration::UserMentions::CreateResourceUserMention, schema: 20200211155539 do - include MigrationsHelpers - - context 'when migrating data' do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:notes) { table(:notes) } - let(:routes) { table(:routes) } - - let(:author) { users.create!(email: 'author@example.com', notification_email: 'author@example.com', name: 'author', username: 'author', projects_limit: 10, state: 'active') } - let(:member) { users.create!(email: 'member@example.com', notification_email: 'member@example.com', name: 'member', username: 'member', projects_limit: 10, state: 'active') } - let(:admin) { users.create!(email: 'administrator@example.com', notification_email: 'administrator@example.com', name: 'administrator', username: 'administrator', admin: 1, projects_limit: 10, state: 'active') } - let(:john_doe) { users.create!(email: 'john_doe@example.com', notification_email: 'john_doe@example.com', name: 'john_doe', username: 'john_doe', projects_limit: 10, state: 'active') } - let(:skipped) { users.create!(email: 'skipped@example.com', notification_email: 'skipped@example.com', name: 'skipped', username: 'skipped', projects_limit: 10, state: 'active') } - - let(:mentioned_users) { [author, member, admin, john_doe, skipped] } - let(:mentioned_users_refs) { mentioned_users.map { |u| "@#{u.username}" }.join(' ') } - - let(:group) { namespaces.create!(name: 'test1', path: 'test1', runners_token: 'my-token1', project_creation_level: 1, visibility_level: 20, type: 'Group') } - let(:inaccessible_group) { namespaces.create!(name: 'test2', path: 'test2', runners_token: 'my-token2', project_creation_level: 1, visibility_level: 0, type: 'Group') } - let(:project) { projects.create!(name: 'gitlab1', path: 'gitlab1', namespace_id: group.id, visibility_level: 0) } - - let(:mentioned_groups) { [group, inaccessible_group] } - let(:group_mentions) { [group, inaccessible_group].map { |gr| "@#{gr.path}" }.join(' ') } - let(:description_mentions) { "description with mentions #{mentioned_users_refs} and #{group_mentions}" } - - before do - # build personal namespaces and routes for users - mentioned_users.each do |u| - namespace = namespaces.create!(path: u.username, name: u.name, runners_token: "my-token-u#{u.id}", owner_id: u.id, type: nil) - routes.create!(path: namespace.path, source_type: 'Namespace', source_id: namespace.id) - end - - # build namespaces and routes for groups - mentioned_groups.each do |gr| - routes.create!(path: gr.path, source_type: 'Namespace', source_id: gr.id) - end - end - - context 'migrate merge request mentions' do - let(:merge_requests) { table(:merge_requests) } - let(:merge_request_user_mentions) { table(:merge_request_user_mentions) } - - let!(:mr1) do - merge_requests.create!( - title: "title 1", state_id: 1, target_branch: 'feature1', source_branch: 'master', - source_project_id: project.id, target_project_id: project.id, author_id: author.id, - description: description_mentions - ) - end - - let!(:mr2) do - merge_requests.create!( - title: "title 2", state_id: 1, target_branch: 'feature2', source_branch: 'master', - source_project_id: project.id, target_project_id: project.id, author_id: author.id, - description: 'some description' - ) - end - - let!(:mr3) do - merge_requests.create!( - title: "title 3", state_id: 1, target_branch: 'feature3', source_branch: 'master', - source_project_id: project.id, target_project_id: project.id, author_id: author.id, - description: 'description with an email@example.com and some other @ char here.') - end - - let(:user_mentions) { merge_request_user_mentions } - let(:resource) { merge_request } - - it_behaves_like 'resource mentions migration', MigrateMergeRequestMentionsToDb, 'MergeRequest' - - context 'when FF disabled' do - before do - stub_feature_flags(migrate_user_mentions: false) - end - - it_behaves_like 'resource migration not run', MigrateMergeRequestMentionsToDb, 'MergeRequest' - end - end - - context 'migrate commit mentions' do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } - let(:commit) { Commit.new(RepoHelpers.sample_commit, project) } - let(:commit_user_mentions) { table(:commit_user_mentions) } - - let!(:note1) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: author.id, note: description_mentions) } - let!(:note2) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: author.id, note: 'sample note') } - let!(:note3) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: author.id, note: description_mentions, system: true) } - - # this not does not have actual mentions - let!(:note4) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: author.id, note: 'note for an email@somesite.com and some other random @ ref' ) } - # this should have pointed to an innexisted commit record in a commits table - # but because commit is not an AR we'll just make it so that it does not have mentions - let!(:note5) { notes.create!(commit_id: 'abc', noteable_type: 'Commit', project_id: project.id, author_id: author.id, note: 'note for an email@somesite.com and some other random @ ref') } - - let(:user_mentions) { commit_user_mentions } - let(:resource) { commit } - - it_behaves_like 'resource notes mentions migration', MigrateCommitNotesMentionsToDb, 'Commit' - - context 'when FF disabled' do - before do - stub_feature_flags(migrate_user_mentions: false) - end - - it_behaves_like 'resource notes migration not run', MigrateCommitNotesMentionsToDb, 'Commit' - end - end - end +RSpec.describe Gitlab::BackgroundMigration::UserMentions::CreateResourceUserMention, schema: 20181228175414 do context 'checks no_quote_columns' do it 'has correct no_quote_columns' do expect(Gitlab::BackgroundMigration::UserMentions::Models::MergeRequest.no_quote_columns).to match([:note_id, :merge_request_id]) diff --git a/spec/lib/gitlab/background_migration/wrongfully_confirmed_email_unconfirmer_spec.rb b/spec/lib/gitlab/background_migration/wrongfully_confirmed_email_unconfirmer_spec.rb index 07f4429f7d9..5c197526a55 100644 --- a/spec/lib/gitlab/background_migration/wrongfully_confirmed_email_unconfirmer_spec.rb +++ b/spec/lib/gitlab/background_migration/wrongfully_confirmed_email_unconfirmer_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::WrongfullyConfirmedEmailUnconfirmer, schema: 20200615111857 do +RSpec.describe Gitlab::BackgroundMigration::WrongfullyConfirmedEmailUnconfirmer, schema: 20181228175414 do let(:users) { table(:users) } let(:emails) { table(:emails) } let(:user_synced_attributes_metadata) { table(:user_synced_attributes_metadata) } diff --git a/spec/lib/gitlab/background_migration_spec.rb b/spec/lib/gitlab/background_migration_spec.rb index f32e6891716..777dc8112a7 100644 --- a/spec/lib/gitlab/background_migration_spec.rb +++ b/spec/lib/gitlab/background_migration_spec.rb @@ -3,6 +3,14 @@ require 'spec_helper' RSpec.describe Gitlab::BackgroundMigration do + let(:coordinator) { described_class::JobCoordinator.for_database(:main) } + + before do + allow(described_class).to receive(:coordinator_for_database) + .with(:main) + .and_return(coordinator) + end + describe '.queue' do it 'returns background migration worker queue' do expect(described_class.queue) @@ -11,7 +19,7 @@ RSpec.describe Gitlab::BackgroundMigration do end describe '.steal' do - context 'when there are enqueued jobs present' do + context 'when the queue contains unprocessed jobs' do let(:queue) do [ double(args: ['Foo', [10, 20]], klass: 'BackgroundMigrationWorker'), @@ -22,110 +30,34 @@ RSpec.describe Gitlab::BackgroundMigration do before do allow(Sidekiq::Queue).to receive(:new) - .with(described_class.queue) + .with(coordinator.queue) .and_return(queue) end - context 'when queue contains unprocessed jobs' do - it 'steals jobs from a queue' do - expect(queue[0]).to receive(:delete).and_return(true) - - expect(described_class).to receive(:perform) - .with('Foo', [10, 20]) - - described_class.steal('Foo') - end - - it 'does not steal job that has already been taken' do - expect(queue[0]).to receive(:delete).and_return(false) - - expect(described_class).not_to receive(:perform) - - described_class.steal('Foo') - end - - it 'does not steal jobs for a different migration' do - expect(described_class).not_to receive(:perform) + it 'uses the coordinator to steal jobs' do + expect(queue[0]).to receive(:delete).and_return(true) - expect(queue[0]).not_to receive(:delete) - - described_class.steal('Baz') - end - - context 'when a custom predicate is given' do - it 'steals jobs that match the predicate' do - expect(queue[0]).to receive(:delete).and_return(true) - - expect(described_class).to receive(:perform) - .with('Foo', [10, 20]) - - described_class.steal('Foo') { |job| job.args.second.first == 10 && job.args.second.second == 20 } - end + expect(coordinator).to receive(:steal).with('Foo', retry_dead_jobs: false).and_call_original + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - it 'does not steal jobs that do not match the predicate' do - expect(described_class).not_to receive(:perform) - - expect(queue[0]).not_to receive(:delete) - - described_class.steal('Foo') { |(arg1, _)| arg1 == 5 } - end - end + described_class.steal('Foo') end - context 'when one of the jobs raises an error' do - let(:migration) { spy(:migration) } - - let(:queue) do - [double(args: ['Foo', [10, 20]], klass: 'BackgroundMigrationWorker'), - double(args: ['Foo', [20, 30]], klass: 'BackgroundMigrationWorker')] - end - - before do - stub_const("#{described_class}::Foo", migration) - - allow(queue[0]).to receive(:delete).and_return(true) - allow(queue[1]).to receive(:delete).and_return(true) - end - - it 'enqueues the migration again and re-raises the error' do - allow(migration).to receive(:perform).with(10, 20) - .and_raise(Exception, 'Migration error').once + context 'when a custom predicate is given' do + it 'steals jobs that match the predicate' do + expect(queue[0]).to receive(:delete).and_return(true) - expect(BackgroundMigrationWorker).to receive(:perform_async) - .with('Foo', [10, 20]).once + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - expect { described_class.steal('Foo') }.to raise_error(Exception) + described_class.steal('Foo') { |job| job.args.second.first == 10 && job.args.second.second == 20 } end - end - end - context 'when there are scheduled jobs present', :redis do - it 'steals all jobs from the scheduled sets' do - Sidekiq::Testing.disable! do - BackgroundMigrationWorker.perform_in(10.minutes, 'Object') - - expect(Sidekiq::ScheduledSet.new).to be_one - expect(described_class).to receive(:perform).with('Object', any_args) - - described_class.steal('Object') + it 'does not steal jobs that do not match the predicate' do + expect(coordinator).not_to receive(:perform) - expect(Sidekiq::ScheduledSet.new).to be_none - end - end - end - - context 'when there are enqueued and scheduled jobs present', :redis do - it 'steals from the scheduled sets queue first' do - Sidekiq::Testing.disable! do - expect(described_class).to receive(:perform) - .with('Object', [1]).ordered - expect(described_class).to receive(:perform) - .with('Object', [2]).ordered - - BackgroundMigrationWorker.perform_async('Object', [2]) - BackgroundMigrationWorker.perform_in(10.minutes, 'Object', [1]) + expect(queue[0]).not_to receive(:delete) - described_class.steal('Object') + described_class.steal('Foo') { |(arg1, _)| arg1 == 5 } end end end @@ -146,14 +78,10 @@ RSpec.describe Gitlab::BackgroundMigration do it 'steals from the dead and retry queue' do Sidekiq::Testing.disable! do - expect(described_class).to receive(:perform) - .with('Object', [1]).ordered - expect(described_class).to receive(:perform) - .with('Object', [2]).ordered - expect(described_class).to receive(:perform) - .with('Object', [3]).ordered - expect(described_class).to receive(:perform) - .with('Object', [4]).ordered + expect(coordinator).to receive(:perform).with('Object', [1]).ordered + expect(coordinator).to receive(:perform).with('Object', [2]).ordered + expect(coordinator).to receive(:perform).with('Object', [3]).ordered + expect(coordinator).to receive(:perform).with('Object', [4]).ordered BackgroundMigrationWorker.perform_async('Object', [2]) BackgroundMigrationWorker.perform_in(10.minutes, 'Object', [1]) @@ -171,131 +99,54 @@ RSpec.describe Gitlab::BackgroundMigration do stub_const("#{described_class.name}::Foo", migration) end - it 'performs a background migration' do + it 'uses the coordinator to perform a background migration' do + expect(coordinator).to receive(:perform).with('Foo', [10, 20]).and_call_original expect(migration).to receive(:perform).with(10, 20).once described_class.perform('Foo', [10, 20]) end + end - context 'backward compatibility' do - it 'performs a background migration for fully-qualified job classes' do - expect(migration).to receive(:perform).with(10, 20).once - expect(Gitlab::ErrorTracking) - .to receive(:track_and_raise_for_dev_exception) - .with(instance_of(StandardError), hash_including(:class_name)) - - described_class.perform('Gitlab::BackgroundMigration::Foo', [10, 20]) + describe '.exists?', :redis do + before do + Sidekiq::Testing.disable! do + MergeWorker.perform_async('Bar') + BackgroundMigrationWorker.perform_async('Foo') end end - end - describe '.remaining', :redis do - context 'when there are jobs remaining' do - before do - Sidekiq::Testing.disable! do - MergeWorker.perform_async('Foo') - MergeWorker.perform_in(10.minutes, 'Foo') - - 5.times do - BackgroundMigrationWorker.perform_async('Foo') - end - 3.times do - BackgroundMigrationWorker.perform_in(10.minutes, 'Foo') - end - end - end + it 'uses the coordinator to find if a job exists' do + expect(coordinator).to receive(:exists?).with('Foo', []).and_call_original - it 'returns the enqueued jobs plus the scheduled jobs' do - expect(described_class.remaining).to eq(8) - end + expect(described_class.exists?('Foo')).to eq(true) end - context 'when there are no jobs remaining' do - it 'returns zero' do - expect(described_class.remaining).to be_zero - end + it 'uses the coordinator to find a job does not exist' do + expect(coordinator).to receive(:exists?).with('Bar', []).and_call_original + + expect(described_class.exists?('Bar')).to eq(false) end end - describe '.exists?', :redis do - context 'when there are enqueued jobs present' do - before do - Sidekiq::Testing.disable! do - MergeWorker.perform_async('Bar') + describe '.remaining', :redis do + before do + Sidekiq::Testing.disable! do + MergeWorker.perform_async('Foo') + MergeWorker.perform_in(10.minutes, 'Foo') + + 5.times do BackgroundMigrationWorker.perform_async('Foo') end - end - - it 'returns true if specific job exists' do - expect(described_class.exists?('Foo')).to eq(true) - end - - it 'returns false if specific job does not exist' do - expect(described_class.exists?('Bar')).to eq(false) - end - end - - context 'when there are scheduled jobs present' do - before do - Sidekiq::Testing.disable! do - MergeWorker.perform_in(10.minutes, 'Bar') + 3.times do BackgroundMigrationWorker.perform_in(10.minutes, 'Foo') end end - - it 'returns true if specific job exists' do - expect(described_class.exists?('Foo')).to eq(true) - end - - it 'returns false if specific job does not exist' do - expect(described_class.exists?('Bar')).to eq(false) - end - end - end - - describe '.dead_jobs?' do - let(:queue) do - [ - double(args: ['Foo', [10, 20]], klass: 'BackgroundMigrationWorker'), - double(args: ['Bar'], klass: 'MergeWorker') - ] end - context 'when there are dead jobs present' do - before do - allow(Sidekiq::DeadSet).to receive(:new).and_return(queue) - end - - it 'returns true if specific job exists' do - expect(described_class.dead_jobs?('Foo')).to eq(true) - end + it 'uses the coordinator to find the number of remaining jobs' do + expect(coordinator).to receive(:remaining).and_call_original - it 'returns false if specific job does not exist' do - expect(described_class.dead_jobs?('Bar')).to eq(false) - end - end - end - - describe '.retrying_jobs?' do - let(:queue) do - [ - double(args: ['Foo', [10, 20]], klass: 'BackgroundMigrationWorker'), - double(args: ['Bar'], klass: 'MergeWorker') - ] - end - - context 'when there are dead jobs present' do - before do - allow(Sidekiq::RetrySet).to receive(:new).and_return(queue) - end - - it 'returns true if specific job exists' do - expect(described_class.retrying_jobs?('Foo')).to eq(true) - end - - it 'returns false if specific job does not exist' do - expect(described_class.retrying_jobs?('Bar')).to eq(false) - end + expect(described_class.remaining).to eq(8) end end end diff --git a/spec/lib/gitlab/bare_repository_import/importer_spec.rb b/spec/lib/gitlab/bare_repository_import/importer_spec.rb index e09430a858c..b0d721a74ce 100644 --- a/spec/lib/gitlab/bare_repository_import/importer_spec.rb +++ b/spec/lib/gitlab/bare_repository_import/importer_spec.rb @@ -89,10 +89,8 @@ RSpec.describe Gitlab::BareRepositoryImport::Importer, :seed_helper do project = Project.find_by_full_path(project_path) repo_path = "#{project.disk_path}.git" - hook_path = File.join(repo_path, 'hooks') expect(gitlab_shell.repository_exists?(project.repository_storage, repo_path)).to be(true) - expect(TestEnv.storage_dir_exists?(project.repository_storage, hook_path)).to be(true) end context 'hashed storage enabled' do diff --git a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb index 4e4d921d67f..f9313f0ff28 100644 --- a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb @@ -142,7 +142,7 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do expect { subject.execute }.to change { MergeRequest.count }.by(1) merge_request = MergeRequest.first - expect(merge_request.author).to eq(pull_request_author) + expect(merge_request.author).to eq(expected_author) end end @@ -151,7 +151,25 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do stub_feature_flags(bitbucket_server_user_mapping_by_username: false) end - include_examples 'imports pull requests' + context 'when email is not present' do + before do + allow(pull_request).to receive(:author_email).and_return(nil) + end + + let(:expected_author) { project_creator } + + include_examples 'imports pull requests' + end + + context 'when email is present' do + before do + allow(pull_request).to receive(:author_email).and_return(pull_request_author.email) + end + + let(:expected_author) { pull_request_author } + + include_examples 'imports pull requests' + end end context 'when bitbucket_server_user_mapping_by_username feature flag is enabled' do @@ -159,19 +177,24 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do stub_feature_flags(bitbucket_server_user_mapping_by_username: true) end - include_examples 'imports pull requests' do - context 'when username is not present' do - before do - allow(pull_request).to receive(:author_username).and_return(nil) - end + context 'when username is not present' do + before do + allow(pull_request).to receive(:author_username).and_return(nil) + end - it 'maps by email' do - expect { subject.execute }.to change { MergeRequest.count }.by(1) + let(:expected_author) { project_creator } - merge_request = MergeRequest.first - expect(merge_request.author).to eq(pull_request_author) - end + include_examples 'imports pull requests' + end + + context 'when username is present' do + before do + allow(pull_request).to receive(:author_username).and_return(pull_request_author.username) end + + let(:expected_author) { pull_request_author } + + include_examples 'imports pull requests' end end @@ -228,7 +251,23 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do allow(subject.client).to receive(:activities).and_return([pr_comment]) end - it 'maps by email' do + it 'defaults to import user' do + expect { subject.execute }.to change { MergeRequest.count }.by(1) + + merge_request = MergeRequest.first + expect(merge_request.notes.count).to eq(1) + note = merge_request.notes.first + expect(note.author).to eq(project_creator) + end + end + + context 'when username is present' do + before do + allow(pr_note).to receive(:author_username).and_return(note_author.username) + allow(subject.client).to receive(:activities).and_return([pr_comment]) + end + + it 'maps by username' do expect { subject.execute }.to change { MergeRequest.count }.by(1) merge_request = MergeRequest.first @@ -241,7 +280,7 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do end context 'metrics' do - let(:histogram) { double(:histogram) } + let(:histogram) { double(:histogram).as_null_object } let(:counter) { double('counter', increment: true) } before do @@ -276,7 +315,6 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do ) expect(counter).to receive(:increment) - allow(histogram).to receive(:observe).with({ importer: :bitbucket_server_importer }, anything) subject.execute end @@ -384,13 +422,13 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do allow(inline_note).to receive(:author_username).and_return(nil) end - it 'maps by email' do + it 'defaults to import user' do expect { subject.execute }.to change { MergeRequest.count }.by(1) notes = MergeRequest.first.notes.order(:id).to_a - expect(notes.first.author).to eq(inline_note_author) - expect(notes.last.author).to eq(reply_author) + expect(notes.first.author).to eq(project_creator) + expect(notes.last.author).to eq(project_creator) end end end diff --git a/spec/lib/gitlab/blob_helper_spec.rb b/spec/lib/gitlab/blob_helper_spec.rb index 65fa5bf0120..a2f20dcd4fc 100644 --- a/spec/lib/gitlab/blob_helper_spec.rb +++ b/spec/lib/gitlab/blob_helper_spec.rb @@ -7,6 +7,7 @@ RSpec.describe Gitlab::BlobHelper do let(:project) { create(:project) } let(:blob) { fake_blob(path: 'file.txt') } + let(:webp_blob) { fake_blob(path: 'file.webp') } let(:large_blob) { fake_blob(path: 'test.pdf', size: 2.megabytes, binary: true) } describe '#extname' do @@ -62,8 +63,15 @@ RSpec.describe Gitlab::BlobHelper do end describe '#image?' do - it 'returns false' do - expect(blob.image?).to be_falsey + context 'with a .txt file' do + it 'returns false' do + expect(blob.image?).to be_falsey + end + end + context 'with a .webp file' do + it 'returns true' do + expect(webp_blob.image?).to be_truthy + end end end diff --git a/spec/lib/gitlab/ci/artifact_file_reader_spec.rb b/spec/lib/gitlab/ci/artifact_file_reader_spec.rb index 83a37655ea9..e982f0eb015 100644 --- a/spec/lib/gitlab/ci/artifact_file_reader_spec.rb +++ b/spec/lib/gitlab/ci/artifact_file_reader_spec.rb @@ -18,17 +18,6 @@ RSpec.describe Gitlab::Ci::ArtifactFileReader do expect(YAML.safe_load(subject).keys).to contain_exactly('rspec', 'time', 'custom') end - context 'when FF ci_new_artifact_file_reader is disabled' do - before do - stub_feature_flags(ci_new_artifact_file_reader: false) - end - - it 'returns the content at the path' do - is_expected.to be_present - expect(YAML.safe_load(subject).keys).to contain_exactly('rspec', 'time', 'custom') - end - end - context 'when path does not exist' do let(:path) { 'file/does/not/exist.txt' } let(:expected_error) do diff --git a/spec/lib/gitlab/ci/artifacts/metrics_spec.rb b/spec/lib/gitlab/ci/artifacts/metrics_spec.rb index 3a2095498ec..0ce76285b03 100644 --- a/spec/lib/gitlab/ci/artifacts/metrics_spec.rb +++ b/spec/lib/gitlab/ci/artifacts/metrics_spec.rb @@ -10,9 +10,9 @@ RSpec.describe Gitlab::Ci::Artifacts::Metrics, :prometheus do let(:counter) { metrics.send(:destroyed_artifacts_counter) } it 'increments a single counter' do - subject.increment_destroyed_artifacts(10) - subject.increment_destroyed_artifacts(20) - subject.increment_destroyed_artifacts(30) + subject.increment_destroyed_artifacts_count(10) + subject.increment_destroyed_artifacts_count(20) + subject.increment_destroyed_artifacts_count(30) expect(counter.get).to eq 60 expect(counter.values.count).to eq 1 diff --git a/spec/lib/gitlab/ci/build/auto_retry_spec.rb b/spec/lib/gitlab/ci/build/auto_retry_spec.rb index fc5999d59ac..9ff9200322e 100644 --- a/spec/lib/gitlab/ci/build/auto_retry_spec.rb +++ b/spec/lib/gitlab/ci/build/auto_retry_spec.rb @@ -25,6 +25,8 @@ RSpec.describe Gitlab::Ci::Build::AutoRetry do "quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | false "no matching runner" | 0 | { max: 2 } | :no_matching_runner | false "missing dependencies" | 0 | { max: 2 } | :missing_dependency_failure | false + "forward deployment failure" | 0 | { max: 2 } | :forward_deployment_failure | false + "environment creation failure" | 0 | { max: 2 } | :environment_creation_failure | false end with_them do diff --git a/spec/lib/gitlab/ci/build/rules/rule/clause/exists_spec.rb b/spec/lib/gitlab/ci/build/rules/rule/clause/exists_spec.rb index 86dd5569a96..f192862c1c4 100644 --- a/spec/lib/gitlab/ci/build/rules/rule/clause/exists_spec.rb +++ b/spec/lib/gitlab/ci/build/rules/rule/clause/exists_spec.rb @@ -3,10 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Exists do - describe '#satisfied_by?' do - let(:pipeline) { build(:ci_pipeline, project: project, sha: project.repository.head_commit.sha) } - - subject { described_class.new(globs).satisfied_by?(pipeline, nil) } + shared_examples 'an exists rule with a context' do + subject { described_class.new(globs).satisfied_by?(pipeline, context) } it_behaves_like 'a glob matching rule' do let(:project) { create(:project, :custom_repo, files: files) } @@ -24,4 +22,26 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Exists do it { is_expected.to be_truthy } end end + + describe '#satisfied_by?' do + let(:pipeline) { build(:ci_pipeline, project: project, sha: project.repository.head_commit.sha) } + + context 'when context is Build::Context::Build' do + it_behaves_like 'an exists rule with a context' do + let(:context) { Gitlab::Ci::Build::Context::Build.new(pipeline, sha: 'abc1234') } + end + end + + context 'when context is Build::Context::Global' do + it_behaves_like 'an exists rule with a context' do + let(:context) { Gitlab::Ci::Build::Context::Global.new(pipeline, yaml_variables: {}) } + end + end + + context 'when context is Config::External::Context' do + it_behaves_like 'an exists rule with a context' do + let(:context) { Gitlab::Ci::Config::External::Context.new(project: project, sha: project.repository.tree.sha) } + end + end + end end diff --git a/spec/lib/gitlab/ci/config/entry/include/rules/rule_spec.rb b/spec/lib/gitlab/ci/config/entry/include/rules/rule_spec.rb index b99048e2c18..0505b17ea91 100644 --- a/spec/lib/gitlab/ci/config/entry/include/rules/rule_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/include/rules/rule_spec.rb @@ -5,7 +5,7 @@ require 'fast_spec_helper' RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do let(:factory) do Gitlab::Config::Entry::Factory.new(described_class) - .value(config) + .value(config) end subject(:entry) { factory.create! } @@ -25,6 +25,12 @@ RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do it { is_expected.to be_valid } end + context 'when specifying an exists: clause' do + let(:config) { { exists: './this.md' } } + + it { is_expected.to be_valid } + end + context 'using a list of multiple expressions' do let(:config) { { if: ['$MY_VAR == "this"', '$YOUR_VAR == "that"'] } } @@ -86,5 +92,13 @@ RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do expect(subject).to eq(if: '$THIS || $THAT') end end + + context 'when specifying an exists: clause' do + let(:config) { { exists: './test.md' } } + + it 'returns the config' do + expect(subject).to eq(exists: './test.md') + end + end end end diff --git a/spec/lib/gitlab/ci/config/entry/processable_spec.rb b/spec/lib/gitlab/ci/config/entry/processable_spec.rb index b872f6644a2..c9c28e2eb8b 100644 --- a/spec/lib/gitlab/ci/config/entry/processable_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/processable_spec.rb @@ -33,6 +33,14 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do end end + context 'when job name is more than 255' do + let(:entry) { node_class.new(config, name: ('a' * 256).to_sym) } + + it 'shows a validation error' do + expect(entry.errors).to include "job name is too long (maximum is 255 characters)" + end + end + context 'when job name is empty' do let(:entry) { node_class.new(config, name: ''.to_sym) } diff --git a/spec/lib/gitlab/ci/config/extendable_spec.rb b/spec/lib/gitlab/ci/config/extendable_spec.rb index 481f55d790e..2fc009569fc 100644 --- a/spec/lib/gitlab/ci/config/extendable_spec.rb +++ b/spec/lib/gitlab/ci/config/extendable_spec.rb @@ -73,6 +73,50 @@ RSpec.describe Gitlab::Ci::Config::Extendable do end end + context 'when the job tries to delete an extension key' do + let(:hash) do + { + something: { + script: 'deploy', + only: { variables: %w[$SOMETHING] } + }, + + test1: { + extends: 'something', + script: 'ls', + only: {} + }, + + test2: { + extends: 'something', + script: 'ls', + only: nil + } + } + end + + it 'deletes the key if assigned to null' do + expect(subject.to_hash).to eq( + something: { + script: 'deploy', + only: { variables: %w[$SOMETHING] } + }, + test1: { + extends: 'something', + script: 'ls', + only: { + variables: %w[$SOMETHING] + } + }, + test2: { + extends: 'something', + script: 'ls', + only: nil + } + ) + end + end + context 'when a hash uses recursive extensions' do let(:hash) do { diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb index c2f28253f54..2e9e6f95071 100644 --- a/spec/lib/gitlab/ci/config/external/processor_spec.rb +++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb @@ -406,7 +406,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'when rules defined' do context 'when a rule is invalid' do let(:values) do - { include: [{ local: 'builds.yml', rules: [{ exists: ['$MY_VAR'] }] }] } + { include: [{ local: 'builds.yml', rules: [{ changes: ['$MY_VAR'] }] }] } end it 'raises IncludeError' do diff --git a/spec/lib/gitlab/ci/config/external/rules_spec.rb b/spec/lib/gitlab/ci/config/external/rules_spec.rb index 9a5c29befa2..1e42cb30ae7 100644 --- a/spec/lib/gitlab/ci/config/external/rules_spec.rb +++ b/spec/lib/gitlab/ci/config/external/rules_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' RSpec.describe Gitlab::Ci::Config::External::Rules do let(:rule_hashes) {} @@ -32,6 +32,26 @@ RSpec.describe Gitlab::Ci::Config::External::Rules do end end + context 'when there is a rule with exists' do + let(:project) { create(:project, :repository) } + let(:context) { double(project: project, sha: project.repository.tree.sha, top_level_worktree_paths: ['test.md']) } + let(:rule_hashes) { [{ exists: 'Dockerfile' }] } + + context 'when the file does not exist' do + it { is_expected.to eq(false) } + end + + context 'when the file exists' do + let(:context) { double(project: project, sha: project.repository.tree.sha, top_level_worktree_paths: ['Dockerfile']) } + + before do + project.repository.create_file(project.owner, 'Dockerfile', "commit", message: 'test', branch_name: "master") + end + + it { is_expected.to eq(true) } + end + end + context 'when there is a rule with if and when' do let(:rule_hashes) { [{ if: '$MY_VAR == "hello"', when: 'on_success' }] } @@ -41,12 +61,12 @@ RSpec.describe Gitlab::Ci::Config::External::Rules do end end - context 'when there is a rule with exists' do - let(:rule_hashes) { [{ exists: ['$MY_VAR'] }] } + context 'when there is a rule with changes' do + let(:rule_hashes) { [{ changes: ['$MY_VAR'] }] } it 'raises an error' do expect { result }.to raise_error(described_class::InvalidIncludeRulesError, - 'invalid include rule: {:exists=>["$MY_VAR"]}') + 'invalid include rule: {:changes=>["$MY_VAR"]}') end end end diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 3ec4519748f..1b3e8a2ce4a 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Gitlab::Ci::Config do end let(:config) do - described_class.new(yml, project: nil, sha: nil, user: nil) + described_class.new(yml, project: nil, pipeline: nil, sha: nil, user: nil) end context 'when config is valid' do @@ -286,9 +286,12 @@ RSpec.describe Gitlab::Ci::Config do end context "when using 'include' directive" do - let(:group) { create(:group) } + let_it_be(:group) { create(:group) } + let(:project) { create(:project, :repository, group: group) } let(:main_project) { create(:project, :repository, :public, group: group) } + let(:pipeline) { build(:ci_pipeline, project: project) } + let(:remote_location) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' } let(:local_location) { 'spec/fixtures/gitlab/ci/external_files/.gitlab-ci-template-1.yml' } @@ -327,7 +330,7 @@ RSpec.describe Gitlab::Ci::Config do end let(:config) do - described_class.new(gitlab_ci_yml, project: project, sha: '12345', user: user) + described_class.new(gitlab_ci_yml, project: project, pipeline: pipeline, sha: '12345', user: user) end before do @@ -594,7 +597,7 @@ RSpec.describe Gitlab::Ci::Config do job1: { script: ["echo 'hello from main file'"], variables: { - VARIABLE_DEFINED_IN_MAIN_FILE: 'some value' + VARIABLE_DEFINED_IN_MAIN_FILE: 'some value' } } }) @@ -725,26 +728,91 @@ RSpec.describe Gitlab::Ci::Config do end context "when an 'include' has rules" do + context "when the rule is an if" do + let(:gitlab_ci_yml) do + <<~HEREDOC + include: + - local: #{local_location} + rules: + - if: $CI_PROJECT_ID == "#{project_id}" + image: ruby:2.7 + HEREDOC + end + + context 'when the rules condition is satisfied' do + let(:project_id) { project.id } + + it 'includes the file' do + expect(config.to_hash).to include(local_location_hash) + end + end + + context 'when the rules condition is satisfied' do + let(:project_id) { non_existing_record_id } + + it 'does not include the file' do + expect(config.to_hash).not_to include(local_location_hash) + end + end + end + + context "when the rule is an exists" do + let(:gitlab_ci_yml) do + <<~HEREDOC + include: + - local: #{local_location} + rules: + - exists: "#{filename}" + image: ruby:2.7 + HEREDOC + end + + before do + project.repository.create_file( + project.creator, + 'my_builds.yml', + local_file_content, + message: 'Add my_builds.yml', + branch_name: '12345' + ) + end + + context 'when the exists file does not exist' do + let(:filename) { 'not_a_real_file.md' } + + it 'does not include the file' do + expect(config.to_hash).not_to include(local_location_hash) + end + end + + context 'when the exists file does exist' do + let(:filename) { 'my_builds.yml' } + + it 'does include the file' do + expect(config.to_hash).to include(local_location_hash) + end + end + end + end + + context "when an 'include' has rules with a pipeline variable" do let(:gitlab_ci_yml) do <<~HEREDOC include: - local: #{local_location} rules: - - if: $CI_PROJECT_ID == "#{project_id}" - image: ruby:2.7 + - if: $CI_COMMIT_SHA == "#{project.commit.sha}" HEREDOC end - context 'when the rules condition is satisfied' do - let(:project_id) { project.id } - + context 'when a pipeline is passed' do it 'includes the file' do expect(config.to_hash).to include(local_location_hash) end end - context 'when the rules condition is satisfied' do - let(:project_id) { non_existing_record_id } + context 'when a pipeline is not passed' do + let(:pipeline) { nil } it 'does not include the file' do expect(config.to_hash).not_to include(local_location_hash) diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb index 16517b39a45..cf21c98dbd5 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb @@ -83,7 +83,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Validate::External do end end - it 'respects the defined payload schema' do + it 'respects the defined payload schema', :saas do expect(::Gitlab::HTTP).to receive(:post) do |_url, params| expect(params[:body]).to match_schema('/external_validation') expect(params[:timeout]).to eq(described_class::DEFAULT_VALIDATION_REQUEST_TIMEOUT) diff --git a/spec/lib/gitlab/ci/pipeline/quota/deployments_spec.rb b/spec/lib/gitlab/ci/pipeline/quota/deployments_spec.rb index c52994fc6a2..5b0917c5c6f 100644 --- a/spec/lib/gitlab/ci/pipeline/quota/deployments_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/quota/deployments_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Pipeline::Quota::Deployments do - let_it_be(:namespace) { create(:namespace) } - let_it_be(:default_plan, reload: true) { create(:default_plan) } - let_it_be(:project, reload: true) { create(:project, :repository, namespace: namespace) } + let_it_be_with_refind(:namespace) { create(:namespace) } + let_it_be_with_reload(:default_plan) { create(:default_plan) } + let_it_be_with_reload(:project) { create(:project, :repository, namespace: namespace) } let_it_be(:plan_limits) { create(:plan_limits, plan: default_plan) } let(:pipeline) { build_stubbed(:ci_pipeline, project: project) } diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 3aa6b2e3c05..e2b64e65938 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do - let_it_be(:project) { create(:project, :repository) } + let_it_be_with_reload(:project) { create(:project, :repository) } let_it_be(:head_sha) { project.repository.head_commit.id } let(:pipeline) { build(:ci_empty_pipeline, project: project, sha: head_sha) } @@ -13,7 +13,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do let(:previous_stages) { [] } let(:current_stage) { double(seeds_names: [attributes[:name]]) } - let(:seed_build) { described_class.new(seed_context, attributes, previous_stages, current_stage) } + let(:seed_build) { described_class.new(seed_context, attributes, previous_stages + [current_stage]) } describe '#attributes' do subject { seed_build.attributes } @@ -393,12 +393,14 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do describe '#to_resource' do subject { seed_build.to_resource } - context 'when job is not a bridge' do + context 'when job is Ci::Build' do it { is_expected.to be_a(::Ci::Build) } it { is_expected.to be_valid } shared_examples_for 'deployment job' do it 'returns a job with deployment' do + expect { subject }.to change { Environment.count }.by(1) + expect(subject.deployment).not_to be_nil expect(subject.deployment.deployable).to eq(subject) expect(subject.deployment.environment.name).to eq(expected_environment_name) @@ -413,6 +415,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do shared_examples_for 'ensures environment existence' do it 'has environment' do + expect { subject }.to change { Environment.count }.by(1) + expect(subject).to be_has_environment expect(subject.environment).to eq(environment_name) expect(subject.metadata.expanded_environment_name).to eq(expected_environment_name) @@ -422,6 +426,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do shared_examples_for 'ensures environment inexistence' do it 'does not have environment' do + expect { subject }.not_to change { Environment.count } + expect(subject).not_to be_has_environment expect(subject.environment).to be_nil expect(subject.metadata&.expanded_environment_name).to be_nil @@ -1212,14 +1218,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do ] end - context 'when FF :variable_inside_variable is enabled' do - before do - stub_feature_flags(variable_inside_variable: [project]) - end - - it "does not have errors" do - expect(subject.errors).to be_empty - end + it "does not have errors" do + expect(subject.errors).to be_empty end end @@ -1232,36 +1232,20 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do ] end - context 'when FF :variable_inside_variable is disabled' do - before do - stub_feature_flags(variable_inside_variable: false) - end - - it "does not have errors" do - expect(subject.errors).to be_empty - end + it "returns an error" do + expect(subject.errors).to contain_exactly( + 'rspec: circular variable reference detected: ["A", "B", "C"]') end - context 'when FF :variable_inside_variable is enabled' do - before do - stub_feature_flags(variable_inside_variable: [project]) - end + context 'with job:rules:[if:]' do + let(:attributes) { { name: 'rspec', ref: 'master', rules: [{ if: '$C != null', when: 'always' }] } } - it "returns an error" do - expect(subject.errors).to contain_exactly( - 'rspec: circular variable reference detected: ["A", "B", "C"]') + it "included? does not raise" do + expect { subject.included? }.not_to raise_error end - context 'with job:rules:[if:]' do - let(:attributes) { { name: 'rspec', ref: 'master', rules: [{ if: '$C != null', when: 'always' }] } } - - it "included? does not raise" do - expect { subject.included? }.not_to raise_error - end - - it "included? returns true" do - expect(subject.included?).to eq(true) - end + it "included? returns true" do + expect(subject.included?).to eq(true) end end end diff --git a/spec/lib/gitlab/ci/reports/security/report_spec.rb b/spec/lib/gitlab/ci/reports/security/report_spec.rb index 5a85c3f19fc..a8b962ee970 100644 --- a/spec/lib/gitlab/ci/reports/security/report_spec.rb +++ b/spec/lib/gitlab/ci/reports/security/report_spec.rb @@ -221,4 +221,26 @@ RSpec.describe Gitlab::Ci::Reports::Security::Report do end end end + + describe '#has_signatures?' do + let(:finding) { create(:ci_reports_security_finding, signatures: signatures) } + + subject { report.has_signatures? } + + before do + report.add_finding(finding) + end + + context 'when the findings of the report does not have signatures' do + let(:signatures) { [] } + + it { is_expected.to be_falsey } + end + + context 'when the findings of the report have signatures' do + let(:signatures) { [instance_double(Gitlab::Ci::Reports::Security::FindingSignature)] } + + it { is_expected.to be_truthy } + end + end end diff --git a/spec/lib/gitlab/ci/reports/security/reports_spec.rb b/spec/lib/gitlab/ci/reports/security/reports_spec.rb index 9b1e02f1418..79eee642552 100644 --- a/spec/lib/gitlab/ci/reports/security/reports_spec.rb +++ b/spec/lib/gitlab/ci/reports/security/reports_spec.rb @@ -54,11 +54,12 @@ RSpec.describe Gitlab::Ci::Reports::Security::Reports do end describe "#violates_default_policy_against?" do - let(:high_severity_dast) { build(:ci_reports_security_finding, severity: 'high', report_type: :dast) } + let(:high_severity_dast) { build(:ci_reports_security_finding, severity: 'high', report_type: 'dast') } let(:vulnerabilities_allowed) { 0 } let(:severity_levels) { %w(critical high) } + let(:vulnerability_states) { %w(newly_detected)} - subject { security_reports.violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels) } + subject { security_reports.violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels, vulnerability_states) } before do security_reports.get_report('sast', artifact).add_finding(high_severity_dast) @@ -108,6 +109,22 @@ RSpec.describe Gitlab::Ci::Reports::Security::Reports do it { is_expected.to be(false) } end + + context 'with related report_types' do + let(:report_types) { %w(dast sast) } + + subject { security_reports.violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels, vulnerability_states, report_types) } + + it { is_expected.to be(true) } + end + + context 'with unrelated report_types' do + let(:report_types) { %w(dependency_scanning sast) } + + subject { security_reports.violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels, vulnerability_states, report_types) } + + it { is_expected.to be(false) } + end end end end diff --git a/spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb index d377cf0c735..789f694b4b4 100644 --- a/spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb +++ b/spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb @@ -27,9 +27,9 @@ RSpec.describe 'Jobs/Deploy.gitlab-ci.yml' do end describe 'the created pipeline' do - let(:project) { create(:project, :repository) } - let(:user) { project.owner } + let_it_be(:project, refind: true) { create(:project, :repository) } + let(:user) { project.owner } let(:default_branch) { 'master' } let(:pipeline_ref) { default_branch } let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) } @@ -43,23 +43,23 @@ RSpec.describe 'Jobs/Deploy.gitlab-ci.yml' do allow(project).to receive(:default_branch).and_return(default_branch) end - context 'with no cluster' do + context 'with no cluster or agent' do it 'does not create any kubernetes deployment jobs' do expect(build_names).to eq %w(placeholder) end end context 'with only a disabled cluster' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp, enabled: false, projects: [project]) } + before do + create(:cluster, :project, :provided_by_gcp, enabled: false, projects: [project]) + end it 'does not create any kubernetes deployment jobs' do expect(build_names).to eq %w(placeholder) end end - context 'with an active cluster' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } - + shared_examples_for 'pipeline with deployment jobs' do context 'on master' do it 'by default' do expect(build_names).to include('production') @@ -218,5 +218,21 @@ RSpec.describe 'Jobs/Deploy.gitlab-ci.yml' do end end end + + context 'with an agent' do + before do + create(:cluster_agent, project: project) + end + + it_behaves_like 'pipeline with deployment jobs' + end + + context 'with a cluster' do + before do + create(:cluster, :project, :provided_by_gcp, projects: [project]) + end + + it_behaves_like 'pipeline with deployment jobs' + end end end diff --git a/spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb new file mode 100644 index 00000000000..b9256ece78b --- /dev/null +++ b/spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Jobs/SAST-IaC.latest.gitlab-ci.yml' do + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Jobs/SAST-IaC.latest') } + + describe 'the created pipeline' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.owner } + + let(:default_branch) { 'main' } + let(:pipeline_ref) { default_branch } + 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) + allow_next_instance_of(Ci::BuildScheduleWorker) do |instance| + allow(instance).to receive(:perform).and_return(true) + end + allow(project).to receive(:default_branch).and_return(default_branch) + end + + context 'on feature branch' do + let(:pipeline_ref) { 'feature' } + + it 'creates the kics-iac-sast job' do + expect(build_names).to contain_exactly('kics-iac-sast') + end + end + + context 'on merge request' do + let(:service) { MergeRequests::CreatePipelineService.new(project: project, current_user: user) } + let(:merge_request) { create(:merge_request, :simple, source_project: project) } + let(:pipeline) { service.execute(merge_request).payload } + + it 'has no jobs' do + expect(pipeline).to be_merge_request_event + expect(build_names).to be_empty + end + end + + context 'SAST_DISABLED is set' do + before do + create(:ci_variable, key: 'SAST_DISABLED', value: 'true', project: project) + end + + context 'on default branch' do + it 'has no jobs' do + expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError) + end + end + + context 'on feature branch' do + let(:pipeline_ref) { 'feature' } + + it 'has no jobs' do + expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError) + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb index 7602309627b..64ef6ecd7f8 100644 --- a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb +++ b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb @@ -148,9 +148,7 @@ RSpec.describe 'Auto-DevOps.gitlab-ci.yml' do it_behaves_like 'no Kubernetes deployment job' end - context 'when the project has an active cluster' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } - + shared_examples 'pipeline with Kubernetes jobs' do describe 'deployment-related builds' do context 'on default branch' do it 'does not include rollout jobs besides production' do @@ -233,6 +231,22 @@ RSpec.describe 'Auto-DevOps.gitlab-ci.yml' do end end end + + context 'when a cluster is attached' do + before do + create(:cluster, :project, :provided_by_gcp, projects: [project]) + end + + it_behaves_like 'pipeline with Kubernetes jobs' + end + + context 'when project has an Agent is present' do + before do + create(:cluster_agent, project: project) + end + + it_behaves_like 'pipeline with Kubernetes jobs' + end end describe 'buildpack detection' do diff --git a/spec/lib/gitlab/ci/templates/kaniko_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/kaniko_gitlab_ci_yaml_spec.rb new file mode 100644 index 00000000000..c7dbbea4622 --- /dev/null +++ b/spec/lib/gitlab/ci/templates/kaniko_gitlab_ci_yaml_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Kaniko.gitlab-ci.yml' do + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Kaniko') } + + describe 'the created pipeline' do + let(:pipeline_branch) { 'master' } + let(:project) { create(:project, :custom_repo, files: { 'Dockerfile' => 'FROM alpine:latest' }) } + let(:user) { project.owner } + 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) + allow(Ci::BuildScheduleWorker).to receive(:perform).and_return(true) + end + + it 'creates "kaniko-build" job' do + expect(build_names).to include('kaniko-build') + end + end +end diff --git a/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb index 3d1306e82a5..fd5d5d6af7f 100644 --- a/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb +++ b/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb @@ -27,7 +27,7 @@ RSpec.describe 'Terraform.latest.gitlab-ci.yml' do context 'on master branch' do it 'creates init, validate and build jobs', :aggregate_failures do expect(pipeline.errors).to be_empty - expect(build_names).to include('init', 'validate', 'build', 'deploy') + expect(build_names).to include('validate', 'build', 'deploy') end end diff --git a/spec/lib/gitlab/ci/trace/archive_spec.rb b/spec/lib/gitlab/ci/trace/archive_spec.rb index c9fc4e720c4..5e965f94347 100644 --- a/spec/lib/gitlab/ci/trace/archive_spec.rb +++ b/spec/lib/gitlab/ci/trace/archive_spec.rb @@ -3,99 +3,134 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Trace::Archive do - let_it_be(:job) { create(:ci_build, :success, :trace_live) } - let_it_be_with_reload(:trace_metadata) { create(:ci_build_trace_metadata, build: job) } - let_it_be(:src_checksum) do - job.trace.read { |stream| Digest::MD5.hexdigest(stream.raw) } - end - - let(:metrics) { spy('metrics') } - - describe '#execute' do - subject { described_class.new(job, trace_metadata, metrics) } - - it 'computes and assigns checksum' do - Gitlab::Ci::Trace::ChunkedIO.new(job) do |stream| - expect { subject.execute!(stream) }.to change { Ci::JobArtifact.count }.by(1) - end - - expect(trace_metadata.checksum).to eq(src_checksum) - expect(trace_metadata.trace_artifact).to eq(job.job_artifacts_trace) + context 'with transactional fixtures' do + let_it_be(:job) { create(:ci_build, :success, :trace_live) } + let_it_be_with_reload(:trace_metadata) { create(:ci_build_trace_metadata, build: job) } + let_it_be(:src_checksum) do + job.trace.read { |stream| Digest::MD5.hexdigest(stream.raw) } end - context 'validating artifact checksum' do - let(:trace) { 'abc' } - let(:stream) { StringIO.new(trace, 'rb') } - let(:src_checksum) { Digest::MD5.hexdigest(trace) } + let(:metrics) { spy('metrics') } - context 'when the object store is disabled' do - before do - stub_artifacts_object_storage(enabled: false) - end - - it 'skips validation' do - subject.execute!(stream) + describe '#execute' do + subject { described_class.new(job, trace_metadata, metrics) } - expect(trace_metadata.checksum).to eq(src_checksum) - expect(trace_metadata.remote_checksum).to be_nil - expect(metrics) - .not_to have_received(:increment_error_counter) - .with(type: :archive_invalid_checksum) + it 'computes and assigns checksum' do + Gitlab::Ci::Trace::ChunkedIO.new(job) do |stream| + expect { subject.execute!(stream) }.to change { Ci::JobArtifact.count }.by(1) end + + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.trace_artifact).to eq(job.job_artifacts_trace) end - context 'with background_upload enabled' do - before do - stub_artifacts_object_storage(background_upload: true) - end + context 'validating artifact checksum' do + let(:trace) { 'abc' } + let(:stream) { StringIO.new(trace, 'rb') } + let(:src_checksum) { Digest::MD5.hexdigest(trace) } - it 'skips validation' do - subject.execute!(stream) + context 'when the object store is disabled' do + before do + stub_artifacts_object_storage(enabled: false) + end - expect(trace_metadata.checksum).to eq(src_checksum) - expect(trace_metadata.remote_checksum).to be_nil - expect(metrics) - .not_to have_received(:increment_error_counter) - .with(type: :archive_invalid_checksum) + it 'skips validation' do + subject.execute!(stream) + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.remote_checksum).to be_nil + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(error_reason: :archive_invalid_checksum) + end end - end - context 'with direct_upload enabled' do - before do - stub_artifacts_object_storage(direct_upload: true) - end + context 'with background_upload enabled' do + before do + stub_artifacts_object_storage(background_upload: true) + end - it 'validates the archived trace' do - subject.execute!(stream) + it 'skips validation' do + subject.execute!(stream) - expect(trace_metadata.checksum).to eq(src_checksum) - expect(trace_metadata.remote_checksum).to eq(src_checksum) - expect(metrics) - .not_to have_received(:increment_error_counter) - .with(type: :archive_invalid_checksum) + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.remote_checksum).to be_nil + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(error_reason: :archive_invalid_checksum) + end end - context 'when the checksum does not match' do - let(:invalid_remote_checksum) { SecureRandom.hex } - + context 'with direct_upload enabled' do before do - expect(::Gitlab::Ci::Trace::RemoteChecksum) - .to receive(:new) - .with(an_instance_of(Ci::JobArtifact)) - .and_return(double(md5_checksum: invalid_remote_checksum)) + stub_artifacts_object_storage(direct_upload: true) end it 'validates the archived trace' do subject.execute!(stream) expect(trace_metadata.checksum).to eq(src_checksum) - expect(trace_metadata.remote_checksum).to eq(invalid_remote_checksum) + expect(trace_metadata.remote_checksum).to eq(src_checksum) expect(metrics) - .to have_received(:increment_error_counter) - .with(type: :archive_invalid_checksum) + .not_to have_received(:increment_error_counter) + .with(error_reason: :archive_invalid_checksum) + end + + context 'when the checksum does not match' do + let(:invalid_remote_checksum) { SecureRandom.hex } + + before do + expect(::Gitlab::Ci::Trace::RemoteChecksum) + .to receive(:new) + .with(an_instance_of(Ci::JobArtifact)) + .and_return(double(md5_checksum: invalid_remote_checksum)) + end + + it 'validates the archived trace' do + subject.execute!(stream) + + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.remote_checksum).to eq(invalid_remote_checksum) + expect(metrics) + .to have_received(:increment_error_counter) + .with(error_reason: :archive_invalid_checksum) + end end end end end end + + context 'without transactional fixtures', :delete do + let(:job) { create(:ci_build, :success, :trace_live) } + let(:trace_metadata) { create(:ci_build_trace_metadata, build: job) } + let(:stream) { StringIO.new('abc', 'rb') } + + describe '#execute!' do + subject(:execute) do + ::Gitlab::Ci::Trace::Archive.new(job, trace_metadata).execute!(stream) + end + + before do + stub_artifacts_object_storage(direct_upload: true) + end + + it 'does not upload the trace inside a database transaction', :delete do + expect(Ci::ApplicationRecord.connection.transaction_open?).to be_falsey + + allow_next_instance_of(Ci::JobArtifact) do |artifact| + artifact.job_id = job.id + + expect(artifact) + .to receive(:store_file!) + .and_wrap_original do |store_method, *args| + expect(Ci::ApplicationRecord.connection.transaction_open?).to be_falsey + + store_method.call(*args) + end + end + + execute + end + end + end end diff --git a/spec/lib/gitlab/ci/trace/metrics_spec.rb b/spec/lib/gitlab/ci/trace/metrics_spec.rb index 53e55a57973..733ffbbea22 100644 --- a/spec/lib/gitlab/ci/trace/metrics_spec.rb +++ b/spec/lib/gitlab/ci/trace/metrics_spec.rb @@ -17,23 +17,23 @@ RSpec.describe Gitlab::Ci::Trace::Metrics, :prometheus do end describe '#increment_error_counter' do - context 'when the operation type is known' do + context 'when the error reason is known' do it 'increments the counter' do - subject.increment_error_counter(type: :chunks_invalid_size) - subject.increment_error_counter(type: :chunks_invalid_checksum) - subject.increment_error_counter(type: :archive_invalid_checksum) + subject.increment_error_counter(error_reason: :chunks_invalid_size) + subject.increment_error_counter(error_reason: :chunks_invalid_checksum) + subject.increment_error_counter(error_reason: :archive_invalid_checksum) - expect(described_class.trace_errors_counter.get(type: :chunks_invalid_size)).to eq 1 - expect(described_class.trace_errors_counter.get(type: :chunks_invalid_checksum)).to eq 1 - expect(described_class.trace_errors_counter.get(type: :archive_invalid_checksum)).to eq 1 + expect(described_class.trace_errors_counter.get(error_reason: :chunks_invalid_size)).to eq 1 + expect(described_class.trace_errors_counter.get(error_reason: :chunks_invalid_checksum)).to eq 1 + expect(described_class.trace_errors_counter.get(error_reason: :archive_invalid_checksum)).to eq 1 expect(described_class.trace_errors_counter.values.count).to eq 3 end end - context 'when the operation type is known' do + context 'when the error reason is unknown' do it 'raises an exception' do - expect { subject.increment_error_counter(type: :invalid_type) } + expect { subject.increment_error_counter(error_reason: :invalid_type) } .to raise_error(ArgumentError) end end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 1a31b2dad56..888ceb7ff9a 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -25,16 +25,6 @@ RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state, factory_defa artifact1.file.migrate!(ObjectStorage::Store::REMOTE) end - it 'reloads the trace after is it migrated' do - stub_const('Gitlab::HttpIO::BUFFER_SIZE', test_data.length) - - expect_next_instance_of(Gitlab::HttpIO) do |http_io| - expect(http_io).to receive(:get_chunk).and_return(test_data, "") - end - - expect(artifact2.job.trace.raw).to eq(test_data) - end - it 'reloads the trace in case of a chunk error' do chunk_error = described_class::ChunkedIO::FailedToGetChunkError diff --git a/spec/lib/gitlab/ci/variables/builder_spec.rb b/spec/lib/gitlab/ci/variables/builder_spec.rb new file mode 100644 index 00000000000..10275f33484 --- /dev/null +++ b/spec/lib/gitlab/ci/variables/builder_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Variables::Builder do + let(:builder) { described_class.new(pipeline) } + let(:pipeline) { create(:ci_pipeline) } + let(:job) { create(:ci_build, pipeline: pipeline) } + + describe '#scoped_variables' do + let(:environment) { job.expanded_environment_name } + let(:dependencies) { true } + + subject { builder.scoped_variables(job, environment: environment, dependencies: dependencies) } + + it 'returns the expected variables' do + keys = %w[CI_JOB_NAME + CI_JOB_STAGE + CI_NODE_TOTAL + CI_BUILD_NAME + CI_BUILD_STAGE] + + subject.map { |env| env[:key] }.tap do |names| + expect(names).to include(*keys) + end + end + + context 'feature flag disabled' do + before do + stub_feature_flags(ci_predefined_vars_in_builder: false) + end + + it 'returns no variables' do + expect(subject.map { |env| env[:key] }).to be_empty + end + end + end +end diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb index 7ba98380986..26c560565e0 100644 --- a/spec/lib/gitlab/ci/variables/collection_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection_spec.rb @@ -358,302 +358,210 @@ RSpec.describe Gitlab::Ci::Variables::Collection do end describe '#sort_and_expand_all' do - context 'when FF :variable_inside_variable is disabled' do - let_it_be(:project_with_flag_disabled) { create(:project) } - let_it_be(:project_with_flag_enabled) { create(:project) } - - before do - stub_feature_flags(variable_inside_variable: [project_with_flag_enabled]) - end + context 'table tests' do + using RSpec::Parameterized::TableSyntax - context 'table tests' do - using RSpec::Parameterized::TableSyntax - - where do - { - "empty array": { - variables: [], - keep_undefined: false - }, - "simple expansions": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'key$variable$variable2' } - ], - keep_undefined: false - }, - "complex expansion": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'key${variable}' } - ], - keep_undefined: false - }, - "out-of-order variable reference": { - variables: [ - { key: 'variable2', value: 'key${variable}' }, - { key: 'variable', value: 'value' } - ], - keep_undefined: false - }, - "complex expansions with raw variable": { - variables: [ - { key: 'variable3', value: 'key_${variable}_${variable2}' }, - { key: 'variable', value: '$variable2', raw: true }, - { key: 'variable2', value: 'value2' } - ], - keep_undefined: false - }, - "escaped characters in complex expansions are kept intact": { - variables: [ - { key: 'variable3', value: 'key_${variable}_$${HOME}_%%HOME%%' }, - { key: 'variable', value: '$variable2' }, - { key: 'variable2', value: 'value2' } - ], - keep_undefined: false - }, - "array with cyclic dependency": { - variables: [ - { key: 'variable', value: '$variable2' }, - { key: 'variable2', value: '$variable3' }, - { key: 'variable3', value: 'key$variable$variable2' } - ], - keep_undefined: true - } + where do + { + "empty array": { + variables: [], + keep_undefined: false, + result: [] + }, + "simple expansions": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key$variable$variable2' }, + { key: 'variable4', value: 'key$variable$variable3' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'keyvalueresult' }, + { key: 'variable4', value: 'keyvaluekeyvalueresult' } + ] + }, + "complex expansion": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'key${variable}' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'keyvalue' } + ] + }, + "unused variables": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result2' }, + { key: 'variable3', value: 'result3' }, + { key: 'variable4', value: 'key$variable$variable3' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result2' }, + { key: 'variable3', value: 'result3' }, + { key: 'variable4', value: 'keyvalueresult3' } + ] + }, + "complex expansions": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key${variable}${variable2}' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'keyvalueresult' } + ] + }, + "escaped characters in complex expansions keeping undefined are kept intact": { + variables: [ + { key: 'variable3', value: 'key_${variable}_$${HOME}_%%HOME%%' }, + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: 'value' } + ], + keep_undefined: true, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'value' }, + { key: 'variable3', value: 'key_value_$${HOME}_%%HOME%%' } + ] + }, + "escaped characters in complex expansions discarding undefined are kept intact": { + variables: [ + { key: 'variable2', value: 'key_${variable4}_$${HOME}_%%HOME%%' }, + { key: 'variable', value: 'value_$${HOME}_%%HOME%%' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value_$${HOME}_%%HOME%%' }, + { key: 'variable2', value: 'key__$${HOME}_%%HOME%%' } + ] + }, + "out-of-order expansion": { + variables: [ + { key: 'variable3', value: 'key$variable2$variable' }, + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' } + ], + keep_undefined: false, + result: [ + { key: 'variable2', value: 'result' }, + { key: 'variable', value: 'value' }, + { key: 'variable3', value: 'keyresultvalue' } + ] + }, + "out-of-order complex expansion": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key${variable2}${variable}' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'keyresultvalue' } + ] + }, + "missing variable discarding original": { + variables: [ + { key: 'variable2', value: 'key$variable' } + ], + keep_undefined: false, + result: [ + { key: 'variable2', value: 'key' } + ] + }, + "missing variable keeping original": { + variables: [ + { key: 'variable2', value: 'key$variable' } + ], + keep_undefined: true, + result: [ + { key: 'variable2', value: 'key$variable' } + ] + }, + "complex expansions with missing variable keeping original": { + variables: [ + { key: 'variable4', value: 'key${variable}${variable2}${variable3}' }, + { key: 'variable', value: 'value' }, + { key: 'variable3', value: 'value3' } + ], + keep_undefined: true, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable3', value: 'value3' }, + { key: 'variable4', value: 'keyvalue${variable2}value3' } + ] + }, + "complex expansions with raw variable": { + variables: [ + { key: 'variable3', value: 'key_${variable}_${variable2}' }, + { key: 'variable', value: '$variable2', raw: true }, + { key: 'variable2', value: 'value2' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: '$variable2', raw: true }, + { key: 'variable2', value: 'value2' }, + { key: 'variable3', value: 'key_$variable2_value2' } + ] + }, + "variable value referencing password with special characters": { + variables: [ + { key: 'VAR', value: '$PASSWORD' }, + { key: 'PASSWORD', value: 'my_password$$_%%_$A' }, + { key: 'A', value: 'value' } + ], + keep_undefined: false, + result: [ + { key: 'VAR', value: 'my_password$$_%%_value' }, + { key: 'PASSWORD', value: 'my_password$$_%%_value' }, + { key: 'A', value: 'value' } + ] + }, + "cyclic dependency causes original array to be returned": { + variables: [ + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2' } + ] } - end - - with_them do - let(:collection) { Gitlab::Ci::Variables::Collection.new(variables, keep_undefined: keep_undefined) } - - subject { collection.sort_and_expand_all(project_with_flag_disabled) } - - it 'returns Collection' do - is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection) - end - - it 'does not expand variables' do - var_hash = variables.pluck(:key, :value).to_h - expect(subject.to_hash).to eq(var_hash) - end - end + } end - end - context 'when FF :variable_inside_variable is enabled' do - let_it_be(:project_with_flag_disabled) { create(:project) } - let_it_be(:project_with_flag_enabled) { create(:project) } + with_them do + let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) } - before do - stub_feature_flags(variable_inside_variable: [project_with_flag_enabled]) - end + subject { collection.sort_and_expand_all(keep_undefined: keep_undefined) } - context 'table tests' do - using RSpec::Parameterized::TableSyntax - - where do - { - "empty array": { - variables: [], - keep_undefined: false, - result: [] - }, - "simple expansions": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'key$variable$variable2' }, - { key: 'variable4', value: 'key$variable$variable3' } - ], - keep_undefined: false, - result: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'keyvalueresult' }, - { key: 'variable4', value: 'keyvaluekeyvalueresult' } - ] - }, - "complex expansion": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'key${variable}' } - ], - keep_undefined: false, - result: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'keyvalue' } - ] - }, - "unused variables": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result2' }, - { key: 'variable3', value: 'result3' }, - { key: 'variable4', value: 'key$variable$variable3' } - ], - keep_undefined: false, - result: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result2' }, - { key: 'variable3', value: 'result3' }, - { key: 'variable4', value: 'keyvalueresult3' } - ] - }, - "complex expansions": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'key${variable}${variable2}' } - ], - keep_undefined: false, - result: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'keyvalueresult' } - ] - }, - "escaped characters in complex expansions keeping undefined are kept intact": { - variables: [ - { key: 'variable3', value: 'key_${variable}_$${HOME}_%%HOME%%' }, - { key: 'variable', value: '$variable2' }, - { key: 'variable2', value: 'value' } - ], - keep_undefined: true, - result: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'value' }, - { key: 'variable3', value: 'key_value_$${HOME}_%%HOME%%' } - ] - }, - "escaped characters in complex expansions discarding undefined are kept intact": { - variables: [ - { key: 'variable2', value: 'key_${variable4}_$${HOME}_%%HOME%%' }, - { key: 'variable', value: 'value_$${HOME}_%%HOME%%' } - ], - keep_undefined: false, - result: [ - { key: 'variable', value: 'value_$${HOME}_%%HOME%%' }, - { key: 'variable2', value: 'key__$${HOME}_%%HOME%%' } - ] - }, - "out-of-order expansion": { - variables: [ - { key: 'variable3', value: 'key$variable2$variable' }, - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' } - ], - keep_undefined: false, - result: [ - { key: 'variable2', value: 'result' }, - { key: 'variable', value: 'value' }, - { key: 'variable3', value: 'keyresultvalue' } - ] - }, - "out-of-order complex expansion": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'key${variable2}${variable}' } - ], - keep_undefined: false, - result: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'keyresultvalue' } - ] - }, - "missing variable discarding original": { - variables: [ - { key: 'variable2', value: 'key$variable' } - ], - keep_undefined: false, - result: [ - { key: 'variable2', value: 'key' } - ] - }, - "missing variable keeping original": { - variables: [ - { key: 'variable2', value: 'key$variable' } - ], - keep_undefined: true, - result: [ - { key: 'variable2', value: 'key$variable' } - ] - }, - "complex expansions with missing variable keeping original": { - variables: [ - { key: 'variable4', value: 'key${variable}${variable2}${variable3}' }, - { key: 'variable', value: 'value' }, - { key: 'variable3', value: 'value3' } - ], - keep_undefined: true, - result: [ - { key: 'variable', value: 'value' }, - { key: 'variable3', value: 'value3' }, - { key: 'variable4', value: 'keyvalue${variable2}value3' } - ] - }, - "complex expansions with raw variable": { - variables: [ - { key: 'variable3', value: 'key_${variable}_${variable2}' }, - { key: 'variable', value: '$variable2', raw: true }, - { key: 'variable2', value: 'value2' } - ], - keep_undefined: false, - result: [ - { key: 'variable', value: '$variable2', raw: true }, - { key: 'variable2', value: 'value2' }, - { key: 'variable3', value: 'key_$variable2_value2' } - ] - }, - "variable value referencing password with special characters": { - variables: [ - { key: 'VAR', value: '$PASSWORD' }, - { key: 'PASSWORD', value: 'my_password$$_%%_$A' }, - { key: 'A', value: 'value' } - ], - keep_undefined: false, - result: [ - { key: 'VAR', value: 'my_password$$_%%_value' }, - { key: 'PASSWORD', value: 'my_password$$_%%_value' }, - { key: 'A', value: 'value' } - ] - }, - "cyclic dependency causes original array to be returned": { - variables: [ - { key: 'variable', value: '$variable2' }, - { key: 'variable2', value: '$variable3' }, - { key: 'variable3', value: 'key$variable$variable2' } - ], - keep_undefined: false, - result: [ - { key: 'variable', value: '$variable2' }, - { key: 'variable2', value: '$variable3' }, - { key: 'variable3', value: 'key$variable$variable2' } - ] - } - } + it 'returns Collection' do + is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection) end - with_them do - let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) } - - subject { collection.sort_and_expand_all(project_with_flag_enabled, keep_undefined: keep_undefined) } - - it 'returns Collection' do - is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection) - end - - it 'expands variables' do - var_hash = result.to_h { |env| [env.fetch(:key), env.fetch(:value)] } - .with_indifferent_access - expect(subject.to_hash).to eq(var_hash) - end + it 'expands variables' do + var_hash = result.to_h { |env| [env.fetch(:key), env.fetch(:value)] } + .with_indifferent_access + expect(subject.to_hash).to eq(var_hash) + end - it 'preserves raw attribute' do - expect(subject.pluck(:key, :raw).to_h).to eq(collection.pluck(:key, :raw).to_h) - end + it 'preserves raw attribute' do + expect(subject.pluck(:key, :raw).to_h).to eq(collection.pluck(:key, :raw).to_h) end end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 1591c2e6b60..f00a801286d 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1046,6 +1046,64 @@ module Gitlab end end + context 'when overriding `extends`' do + let(:config) do + <<~YAML + .base: + script: test + variables: + VAR1: base var 1 + + test1: + extends: .base + variables: + VAR1: test1 var 1 + VAR2: test2 var 2 + + test2: + extends: .base + variables: + VAR2: test2 var 2 + + test3: + extends: .base + variables: {} + + test4: + extends: .base + variables: null + YAML + end + + it 'correctly extends jobs' do + expect(config_processor.builds[0]).to include( + name: 'test1', + options: { script: ['test'] }, + job_variables: [{ key: 'VAR1', value: 'test1 var 1', public: true }, + { key: 'VAR2', value: 'test2 var 2', public: true }] + ) + + expect(config_processor.builds[1]).to include( + name: 'test2', + options: { script: ['test'] }, + job_variables: [{ key: 'VAR1', value: 'base var 1', public: true }, + { key: 'VAR2', value: 'test2 var 2', public: true }] + ) + + expect(config_processor.builds[2]).to include( + name: 'test3', + options: { script: ['test'] }, + job_variables: [{ key: 'VAR1', value: 'base var 1', public: true }] + ) + + expect(config_processor.builds[3]).to include( + name: 'test4', + options: { script: ['test'] }, + job_variables: [] + ) + end + end + context 'when using recursive `extends`' do let(:config) do <<~YAML diff --git a/spec/lib/gitlab/config_checker/external_database_checker_spec.rb b/spec/lib/gitlab/config_checker/external_database_checker_spec.rb index 5a4e9001ac9..933b6d6be9e 100644 --- a/spec/lib/gitlab/config_checker/external_database_checker_spec.rb +++ b/spec/lib/gitlab/config_checker/external_database_checker_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::ConfigChecker::ExternalDatabaseChecker do context 'when database meets minimum supported version' do before do - allow(Gitlab::Database.main).to receive(:postgresql_minimum_supported_version?).and_return(true) + allow(ApplicationRecord.database).to receive(:postgresql_minimum_supported_version?).and_return(true) end it { is_expected.to be_empty } @@ -16,7 +16,7 @@ RSpec.describe Gitlab::ConfigChecker::ExternalDatabaseChecker do context 'when database does not meet minimum supported version' do before do - allow(Gitlab::Database.main).to receive(:postgresql_minimum_supported_version?).and_return(false) + allow(ApplicationRecord.database).to receive(:postgresql_minimum_supported_version?).and_return(false) end let(:notice_deprecated_database) do @@ -26,7 +26,7 @@ RSpec.describe Gitlab::ConfigChecker::ExternalDatabaseChecker do '%{pg_version_minimum} is required for this version of GitLab. ' \ 'Please upgrade your environment to a supported PostgreSQL version, ' \ 'see %{pg_requirements_url} for details.') % { - pg_version_current: Gitlab::Database.main.version, + pg_version_current: ApplicationRecord.database.version, pg_version_minimum: Gitlab::Database::MINIMUM_POSTGRES_VERSION, pg_requirements_url: 'database requirements' } diff --git a/spec/lib/gitlab/container_repository/tags/cache_spec.rb b/spec/lib/gitlab/container_repository/tags/cache_spec.rb new file mode 100644 index 00000000000..f84c1ce173f --- /dev/null +++ b/spec/lib/gitlab/container_repository/tags/cache_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::ContainerRepository::Tags::Cache, :clean_gitlab_redis_cache do + let_it_be(:dummy_tag_class) { Struct.new(:name, :created_at) } + let_it_be(:repository) { create(:container_repository) } + + let(:tags) { create_tags(5) } + let(:service) { described_class.new(repository) } + + shared_examples 'not interacting with redis' do + it 'does not interact with redis' do + expect(::Gitlab::Redis::Cache).not_to receive(:with) + + subject + end + end + + describe '#populate' do + subject { service.populate(tags) } + + context 'with tags' do + it 'gets values from redis' do + expect(::Gitlab::Redis::Cache).to receive(:with).and_call_original + + expect(subject).to eq(0) + + tags.each { |t| expect(t.created_at).to eq(nil) } + end + + context 'with cached values' do + let(:cached_tags) { tags.first(2) } + + before do + ::Gitlab::Redis::Cache.with do |redis| + cached_tags.each do |tag| + redis.set(cache_key(tag), rfc3339(10.days.ago)) + end + end + end + + it 'gets values from redis' do + expect(::Gitlab::Redis::Cache).to receive(:with).and_call_original + + expect(subject).to eq(2) + + cached_tags.each { |t| expect(t.created_at).not_to eq(nil) } + (tags - cached_tags).each { |t| expect(t.created_at).to eq(nil) } + end + end + end + + context 'with no tags' do + let(:tags) { [] } + + it_behaves_like 'not interacting with redis' + end + end + + describe '#insert' do + let(:max_ttl) { 90.days } + + subject { service.insert(tags, max_ttl) } + + context 'with tags' do + let(:tag) { tags.first } + let(:ttl) { 90.days - 3.days } + + before do + travel_to(Time.zone.local(2021, 9, 2, 12, 0, 0)) + + tag.created_at = DateTime.rfc3339(3.days.ago.rfc3339) + end + + after do + travel_back + end + + it 'inserts values in redis' do + ::Gitlab::Redis::Cache.with do |redis| + expect(redis) + .to receive(:set) + .with(cache_key(tag), rfc3339(tag.created_at), ex: ttl.to_i) + .and_call_original + end + + subject + end + + context 'with some of them already cached' do + let(:tag) { tags.first } + + before do + ::Gitlab::Redis::Cache.with do |redis| + redis.set(cache_key(tag), rfc3339(10.days.ago)) + end + service.populate(tags) + end + + it_behaves_like 'not interacting with redis' + end + end + + context 'with no tags' do + let(:tags) { [] } + + it_behaves_like 'not interacting with redis' + end + + context 'with no expires_in' do + let(:max_ttl) { nil } + + it_behaves_like 'not interacting with redis' + end + end + + def create_tags(size) + Array.new(size) do |i| + dummy_tag_class.new("Tag #{i}", nil) + end + end + + def cache_key(tag) + "container_repository:{#{repository.id}}:tag:#{tag.name}:created_at" + end + + def rfc3339(date_time) + # DateTime rfc3339 is different ActiveSupport::TimeWithZone rfc3339 + # The caching will use DateTime rfc3339 + DateTime.rfc3339(date_time.rfc3339).rfc3339 + end +end diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb index 3ec332dace5..c0476d38380 100644 --- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb +++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb @@ -50,7 +50,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do expect(directives.has_key?('report_uri')).to be_truthy expect(directives['report_uri']).to be_nil - expect(directives['child_src']).to eq(directives['frame_src']) + expect(directives['child_src']).to eq("#{directives['frame_src']} #{directives['worker_src']}") end context 'adds all websocket origins to support Safari' do @@ -77,13 +77,15 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do context 'when CDN host is defined' do before do - stub_config_setting(cdn_host: 'https://example.com') + stub_config_setting(cdn_host: 'https://cdn.example.com') end it 'adds CDN host to CSP' do - expect(directives['script_src']).to eq("'strict-dynamic' 'self' 'unsafe-inline' 'unsafe-eval' https://www.google.com/recaptcha/ https://www.recaptcha.net https://apis.google.com https://example.com") - expect(directives['style_src']).to eq("'self' 'unsafe-inline' https://example.com") - expect(directives['font_src']).to eq("'self' https://example.com") + expect(directives['script_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.script_src + " https://cdn.example.com") + expect(directives['style_src']).to eq("'self' 'unsafe-inline' https://cdn.example.com") + expect(directives['font_src']).to eq("'self' https://cdn.example.com") + expect(directives['worker_src']).to eq('http://localhost/assets/ blob: data: https://cdn.example.com') + expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " https://cdn.example.com http://localhost/admin/sidekiq http://localhost/admin/sidekiq/ http://localhost/-/speedscope/index.html") end end @@ -99,8 +101,10 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do end context 'when CUSTOMER_PORTAL_URL is set' do + let(:customer_portal_url) { 'https://customers.example.com' } + before do - stub_env('CUSTOMER_PORTAL_URL', 'https://customers.example.com') + stub_env('CUSTOMER_PORTAL_URL', customer_portal_url) end context 'when in production' do @@ -109,7 +113,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do end it 'does not add CUSTOMER_PORTAL_URL to CSP' do - expect(directives['frame_src']).to eq("'self' https://www.google.com/recaptcha/ https://www.recaptcha.net/ https://content.googleapis.com https://content-compute.googleapis.com https://content-cloudbilling.googleapis.com https://content-cloudresourcemanager.googleapis.com") + expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/sidekiq http://localhost/admin/sidekiq/ http://localhost/-/speedscope/index.html") end end @@ -119,7 +123,36 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do end it 'adds CUSTOMER_PORTAL_URL to CSP' do - expect(directives['frame_src']).to eq("'self' https://www.google.com/recaptcha/ https://www.recaptcha.net/ https://content.googleapis.com https://content-compute.googleapis.com https://content-cloudbilling.googleapis.com https://content-cloudresourcemanager.googleapis.com https://customers.example.com") + expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/rails/letter_opener/ https://customers.example.com http://localhost/admin/sidekiq http://localhost/admin/sidekiq/ http://localhost/-/speedscope/index.html") + end + end + end + + context 'letter_opener applicaiton URL' do + let(:gitlab_url) { 'http://gitlab.example.com' } + let(:letter_opener_url) { "#{gitlab_url}/rails/letter_opener/" } + + before do + stub_config_setting(url: gitlab_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 letter_opener to CSP' do + expect(directives['frame_src']).not_to include(letter_opener_url) + end + end + + context 'when in development' do + before do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development')) + end + + it 'adds letter_opener to CSP' do + expect(directives['frame_src']).to include(letter_opener_url) end end end diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb index 67b2ea7a1d4..384609c6664 100644 --- a/spec/lib/gitlab/contributions_calendar_spec.rb +++ b/spec/lib/gitlab/contributions_calendar_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::ContributionsCalendar do let(:contributor) { create(:user) } let(:user) { create(:user) } + let(:travel_time) { nil } let(:private_project) do create(:project, :private) do |project| @@ -31,7 +32,7 @@ RSpec.describe Gitlab::ContributionsCalendar do let(:last_year) { today - 1.year } before do - travel_to Time.now.utc.end_of_day + travel_to travel_time || Time.now.utc.end_of_day end after do @@ -89,7 +90,7 @@ RSpec.describe Gitlab::ContributionsCalendar do expect(calendar(contributor).activity_dates[today]).to eq(2) end - context "when events fall under different dates depending on the time zone" do + context "when events fall under different dates depending on the system time zone" do before do create_event(public_project, today, 1) create_event(public_project, today, 4) @@ -116,6 +117,37 @@ RSpec.describe Gitlab::ContributionsCalendar do end end end + + context "when events fall under different dates depending on the contributor's time zone" do + before do + create_event(public_project, today, 1) + create_event(public_project, today, 4) + create_event(public_project, today, 10) + create_event(public_project, today, 16) + create_event(public_project, today, 23) + end + + it "renders correct event counts within the UTC timezone" do + Time.use_zone('UTC') do + contributor.timezone = 'UTC' + expect(calendar.activity_dates).to eq(today => 5) + end + end + + it "renders correct event counts within the Sydney timezone" do + Time.use_zone('UTC') do + contributor.timezone = 'Sydney' + expect(calendar.activity_dates).to eq(today => 3, tomorrow => 2) + end + end + + it "renders correct event counts within the US Central timezone" do + Time.use_zone('UTC') do + contributor.timezone = 'Central Time (US & Canada)' + expect(calendar.activity_dates).to eq(yesterday => 2, today => 3) + end + end + end end describe '#events_by_date' do @@ -152,14 +184,38 @@ RSpec.describe Gitlab::ContributionsCalendar do end describe '#starting_year' do - it "is the start of last year" do - expect(calendar.starting_year).to eq(last_year.year) + let(:travel_time) { Time.find_zone('UTC').local(2020, 12, 31, 19, 0, 0) } + + context "when the contributor's timezone is not set" do + it "is the start of last year in the system timezone" do + expect(calendar.starting_year).to eq(2019) + end + end + + context "when the contributor's timezone is set to Sydney" do + let(:contributor) { create(:user, { timezone: 'Sydney' }) } + + it "is the start of last year in Sydney" do + expect(calendar.starting_year).to eq(2020) + end end end describe '#starting_month' do - it "is the start of this month" do - expect(calendar.starting_month).to eq(today.month) + let(:travel_time) { Time.find_zone('UTC').local(2020, 12, 31, 19, 0, 0) } + + context "when the contributor's timezone is not set" do + it "is the start of this month in the system timezone" do + expect(calendar.starting_month).to eq(12) + end + end + + context "when the contributor's timezone is set to Sydney" do + let(:contributor) { create(:user, { timezone: 'Sydney' }) } + + it "is the start of this month in Sydney" do + expect(calendar.starting_month).to eq(1) + end end end end diff --git a/spec/lib/gitlab/database/async_indexes/postgres_async_index_spec.rb b/spec/lib/gitlab/database/async_indexes/postgres_async_index_spec.rb index 434cba4edde..223730f87c0 100644 --- a/spec/lib/gitlab/database/async_indexes/postgres_async_index_spec.rb +++ b/spec/lib/gitlab/database/async_indexes/postgres_async_index_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Database::AsyncIndexes::PostgresAsyncIndex, type: :model do + it { is_expected.to be_a Gitlab::Database::SharedModel } + describe 'validations' do let(:identifier_limit) { described_class::MAX_IDENTIFIER_LENGTH } let(:definition_limit) { described_class::MAX_DEFINITION_LENGTH } 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 779e8e40c97..04c18a98ee6 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 @@ -286,7 +286,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do let(:migration_wrapper) { Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper.new } let(:migration_helpers) { ActiveRecord::Migration.new } - let(:table_name) { :_batched_migrations_test_table } + let(:table_name) { :_test_batched_migrations_test_table } let(:column_name) { :some_id } let(:job_arguments) { [:some_id, :some_id_convert_to_bigint] } diff --git a/spec/lib/gitlab/database/batch_count_spec.rb b/spec/lib/gitlab/database/batch_count_spec.rb index da13bc425d1..9831510f014 100644 --- a/spec/lib/gitlab/database/batch_count_spec.rb +++ b/spec/lib/gitlab/database/batch_count_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Gitlab::Database::BatchCount do end before do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(in_transaction) + allow(model.connection).to receive(:transaction_open?).and_return(in_transaction) end def calculate_batch_size(batch_size) diff --git a/spec/lib/gitlab/database/connection_spec.rb b/spec/lib/gitlab/database/connection_spec.rb deleted file mode 100644 index ee1df141cd6..00000000000 --- a/spec/lib/gitlab/database/connection_spec.rb +++ /dev/null @@ -1,442 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Database::Connection do - let(:connection) { described_class.new } - - describe '#config' do - it 'returns a HashWithIndifferentAccess' do - expect(connection.config).to be_an_instance_of(HashWithIndifferentAccess) - end - - it 'returns a default pool size' do - expect(connection.config) - .to include(pool: Gitlab::Database.default_pool_size) - end - - it 'does not cache its results' do - a = connection.config - b = connection.config - - expect(a).not_to equal(b) - end - end - - describe '#pool_size' do - context 'when no explicit size is configured' do - it 'returns the default pool size' do - expect(connection).to receive(:config).and_return({ pool: nil }) - - expect(connection.pool_size).to eq(Gitlab::Database.default_pool_size) - end - end - - context 'when an explicit pool size is set' do - it 'returns the pool size' do - expect(connection).to receive(:config).and_return({ pool: 4 }) - - expect(connection.pool_size).to eq(4) - end - end - end - - describe '#username' do - context 'when a username is set' do - it 'returns the username' do - allow(connection).to receive(:config).and_return(username: 'bob') - - expect(connection.username).to eq('bob') - end - end - - context 'when a username is not set' do - it 'returns the value of the USER environment variable' do - allow(connection).to receive(:config).and_return(username: nil) - allow(ENV).to receive(:[]).with('USER').and_return('bob') - - expect(connection.username).to eq('bob') - end - end - end - - describe '#database_name' do - it 'returns the name of the database' do - allow(connection).to receive(:config).and_return(database: 'test') - - expect(connection.database_name).to eq('test') - end - end - - describe '#adapter_name' do - it 'returns the database adapter name' do - allow(connection).to receive(:config).and_return(adapter: 'test') - - expect(connection.adapter_name).to eq('test') - end - end - - describe '#human_adapter_name' do - context 'when the adapter is PostgreSQL' do - it 'returns PostgreSQL' do - allow(connection).to receive(:config).and_return(adapter: 'postgresql') - - expect(connection.human_adapter_name).to eq('PostgreSQL') - end - end - - context 'when the adapter is not PostgreSQL' do - it 'returns Unknown' do - allow(connection).to receive(:config).and_return(adapter: 'kittens') - - expect(connection.human_adapter_name).to eq('Unknown') - end - end - end - - describe '#postgresql?' do - context 'when using PostgreSQL' do - it 'returns true' do - allow(connection).to receive(:adapter_name).and_return('PostgreSQL') - - expect(connection.postgresql?).to eq(true) - end - end - - context 'when not using PostgreSQL' do - it 'returns false' do - allow(connection).to receive(:adapter_name).and_return('MySQL') - - expect(connection.postgresql?).to eq(false) - end - end - end - - describe '#db_config_with_default_pool_size' do - it 'returns db_config with our default pool size' do - allow(Gitlab::Database).to receive(:default_pool_size).and_return(9) - - expect(connection.db_config_with_default_pool_size.pool).to eq(9) - end - - it 'returns db_config with the correct database name' do - db_name = connection.scope.connection.pool.db_config.name - - expect(connection.db_config_with_default_pool_size.name).to eq(db_name) - end - end - - describe '#disable_prepared_statements', :reestablished_active_record_base do - it 'disables prepared statements' do - connection.scope.establish_connection( - ::Gitlab::Database.main.config.merge(prepared_statements: true) - ) - - expect(connection.scope.connection.prepared_statements).to eq(true) - - connection.disable_prepared_statements - - expect(connection.scope.connection.prepared_statements).to eq(false) - end - - it 'retains the connection name' do - connection.disable_prepared_statements - - expect(connection.scope.connection_db_config.name).to eq('main') - end - - context 'with dynamic connection pool size' do - before do - connection.scope.establish_connection(connection.config.merge(pool: 7)) - end - - it 'retains the set pool size' do - connection.disable_prepared_statements - - expect(connection.scope.connection.prepared_statements).to eq(false) - expect(connection.scope.connection.pool.size).to eq(7) - end - end - end - - describe '#db_read_only?' do - it 'detects a read-only database' do - allow(connection.scope.connection) - .to receive(:execute) - .with('SELECT pg_is_in_recovery()') - .and_return([{ "pg_is_in_recovery" => "t" }]) - - expect(connection.db_read_only?).to be_truthy - end - - it 'detects a read-only database' do - allow(connection.scope.connection) - .to receive(:execute) - .with('SELECT pg_is_in_recovery()') - .and_return([{ "pg_is_in_recovery" => true }]) - - expect(connection.db_read_only?).to be_truthy - end - - it 'detects a read-write database' do - allow(connection.scope.connection) - .to receive(:execute) - .with('SELECT pg_is_in_recovery()') - .and_return([{ "pg_is_in_recovery" => "f" }]) - - expect(connection.db_read_only?).to be_falsey - end - - it 'detects a read-write database' do - allow(connection.scope.connection) - .to receive(:execute) - .with('SELECT pg_is_in_recovery()') - .and_return([{ "pg_is_in_recovery" => false }]) - - expect(connection.db_read_only?).to be_falsey - end - end - - describe '#db_read_write?' do - it 'detects a read-only database' do - allow(connection.scope.connection) - .to receive(:execute) - .with('SELECT pg_is_in_recovery()') - .and_return([{ "pg_is_in_recovery" => "t" }]) - - expect(connection.db_read_write?).to eq(false) - end - - it 'detects a read-only database' do - allow(connection.scope.connection) - .to receive(:execute) - .with('SELECT pg_is_in_recovery()') - .and_return([{ "pg_is_in_recovery" => true }]) - - expect(connection.db_read_write?).to eq(false) - end - - it 'detects a read-write database' do - allow(connection.scope.connection) - .to receive(:execute) - .with('SELECT pg_is_in_recovery()') - .and_return([{ "pg_is_in_recovery" => "f" }]) - - expect(connection.db_read_write?).to eq(true) - end - - it 'detects a read-write database' do - allow(connection.scope.connection) - .to receive(:execute) - .with('SELECT pg_is_in_recovery()') - .and_return([{ "pg_is_in_recovery" => false }]) - - expect(connection.db_read_write?).to eq(true) - end - end - - describe '#version' do - around do |example| - connection.instance_variable_set(:@version, nil) - example.run - connection.instance_variable_set(:@version, nil) - end - - context "on postgresql" do - it "extracts the version number" do - allow(connection) - .to receive(:database_version) - .and_return("PostgreSQL 9.4.4 on x86_64-apple-darwin14.3.0") - - expect(connection.version).to eq '9.4.4' - end - end - - it 'memoizes the result' do - count = ActiveRecord::QueryRecorder - .new { 2.times { connection.version } } - .count - - expect(count).to eq(1) - end - end - - describe '#postgresql_minimum_supported_version?' do - it 'returns false when using PostgreSQL 10' do - allow(connection).to receive(:version).and_return('10') - - expect(connection.postgresql_minimum_supported_version?).to eq(false) - end - - it 'returns false when using PostgreSQL 11' do - allow(connection).to receive(:version).and_return('11') - - expect(connection.postgresql_minimum_supported_version?).to eq(false) - end - - it 'returns true when using PostgreSQL 12' do - allow(connection).to receive(:version).and_return('12') - - expect(connection.postgresql_minimum_supported_version?).to eq(true) - end - end - - describe '#bulk_insert' do - before do - allow(connection).to receive(:connection).and_return(dummy_connection) - allow(dummy_connection).to receive(:quote_column_name, &:itself) - allow(dummy_connection).to receive(:quote, &:itself) - allow(dummy_connection).to receive(:execute) - end - - let(:dummy_connection) { double(:connection) } - - let(:rows) do - [ - { a: 1, b: 2, c: 3 }, - { c: 6, a: 4, b: 5 } - ] - end - - it 'does nothing with empty rows' do - expect(dummy_connection).not_to receive(:execute) - - connection.bulk_insert('test', []) - end - - it 'uses the ordering from the first row' do - expect(dummy_connection).to receive(:execute) do |sql| - expect(sql).to include('(1, 2, 3)') - expect(sql).to include('(4, 5, 6)') - end - - connection.bulk_insert('test', rows) - end - - it 'quotes column names' do - expect(dummy_connection).to receive(:quote_column_name).with(:a) - expect(dummy_connection).to receive(:quote_column_name).with(:b) - expect(dummy_connection).to receive(:quote_column_name).with(:c) - - connection.bulk_insert('test', rows) - end - - it 'quotes values' do - 1.upto(6) do |i| - expect(dummy_connection).to receive(:quote).with(i) - end - - connection.bulk_insert('test', rows) - end - - it 'does not quote values of a column in the disable_quote option' do - [1, 2, 4, 5].each do |i| - expect(dummy_connection).to receive(:quote).with(i) - end - - connection.bulk_insert('test', rows, disable_quote: :c) - end - - it 'does not quote values of columns in the disable_quote option' do - [2, 5].each do |i| - expect(dummy_connection).to receive(:quote).with(i) - end - - connection.bulk_insert('test', rows, disable_quote: [:a, :c]) - end - - it 'handles non-UTF-8 data' do - expect { connection.bulk_insert('test', [{ a: "\255" }]) }.not_to raise_error - end - - context 'when using PostgreSQL' do - it 'allows the returning of the IDs of the inserted rows' do - result = double(:result, values: [['10']]) - - expect(dummy_connection) - .to receive(:execute) - .with(/RETURNING id/) - .and_return(result) - - ids = connection - .bulk_insert('test', [{ number: 10 }], return_ids: true) - - expect(ids).to eq([10]) - end - - it 'allows setting the upsert to do nothing' do - expect(dummy_connection) - .to receive(:execute) - .with(/ON CONFLICT DO NOTHING/) - - connection - .bulk_insert('test', [{ number: 10 }], on_conflict: :do_nothing) - end - end - end - - describe '#cached_column_exists?' do - it 'only retrieves the data from the schema cache' do - queries = ActiveRecord::QueryRecorder.new do - 2.times do - expect(connection.cached_column_exists?(:projects, :id)).to be_truthy - expect(connection.cached_column_exists?(:projects, :bogus_column)).to be_falsey - end - end - - expect(queries.count).to eq(0) - end - end - - describe '#cached_table_exists?' do - it 'only retrieves the data from the schema cache' do - queries = ActiveRecord::QueryRecorder.new do - 2.times do - expect(connection.cached_table_exists?(:projects)).to be_truthy - expect(connection.cached_table_exists?(:bogus_table_name)).to be_falsey - end - end - - expect(queries.count).to eq(0) - end - - it 'returns false when database does not exist' do - expect(connection.scope).to receive(:connection) do - raise ActiveRecord::NoDatabaseError, 'broken' - end - - expect(connection.cached_table_exists?(:projects)).to be(false) - end - end - - describe '#exists?' do - it 'returns true if the database exists' do - expect(connection.exists?).to be(true) - end - - it "returns false if the database doesn't exist" do - expect(connection.scope.connection.schema_cache) - .to receive(:database_version) - .and_raise(ActiveRecord::NoDatabaseError) - - expect(connection.exists?).to be(false) - end - end - - describe '#system_id' do - it 'returns the PostgreSQL system identifier' do - expect(connection.system_id).to be_an_instance_of(Integer) - end - end - - describe '#get_write_location' do - it 'returns a string' do - expect(connection.get_write_location(connection.scope.connection)) - .to be_a(String) - end - - it 'returns nil if there are no results' do - expect(connection.get_write_location(double(select_all: []))).to be_nil - end - end -end diff --git a/spec/lib/gitlab/database/count/reltuples_count_strategy_spec.rb b/spec/lib/gitlab/database/count/reltuples_count_strategy_spec.rb index cdcc862c376..9d49db1f018 100644 --- a/spec/lib/gitlab/database/count/reltuples_count_strategy_spec.rb +++ b/spec/lib/gitlab/database/count/reltuples_count_strategy_spec.rb @@ -38,7 +38,8 @@ RSpec.describe Gitlab::Database::Count::ReltuplesCountStrategy do it 'returns nil counts for inherited tables' do models.each { |model| expect(model).not_to receive(:count) } - expect(subject).to eq({ Namespace => 3 }) + # 3 Namespaces as parents for each Project and 3 ProjectNamespaces(for each Project) + expect(subject).to eq({ Namespace => 6 }) end end diff --git a/spec/lib/gitlab/database/count/tablesample_count_strategy_spec.rb b/spec/lib/gitlab/database/count/tablesample_count_strategy_spec.rb index c2028f8c238..2f261aebf02 100644 --- a/spec/lib/gitlab/database/count/tablesample_count_strategy_spec.rb +++ b/spec/lib/gitlab/database/count/tablesample_count_strategy_spec.rb @@ -47,7 +47,8 @@ RSpec.describe Gitlab::Database::Count::TablesampleCountStrategy do result = subject expect(result[Project]).to eq(3) expect(result[Group]).to eq(1) - expect(result[Namespace]).to eq(4) + # 1-Group, 3 namespaces for each project and 3 project namespaces for each project + expect(result[Namespace]).to eq(7) end end diff --git a/spec/lib/gitlab/database/each_database_spec.rb b/spec/lib/gitlab/database/each_database_spec.rb new file mode 100644 index 00000000000..9327fc4ff78 --- /dev/null +++ b/spec/lib/gitlab/database/each_database_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::EachDatabase do + describe '.each_database_connection' do + let(:expected_connections) do + Gitlab::Database.database_base_models.map { |name, model| [model.connection, name] } + end + + it 'yields each connection after connecting SharedModel' do + expected_connections.each do |connection, _| + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(connection).and_yield + end + + yielded_connections = [] + + described_class.each_database_connection do |connection, name| + yielded_connections << [connection, name] + end + + expect(yielded_connections).to match_array(expected_connections) + end + end + + describe '.each_model_connection' do + let(:model1) { double(connection: double, table_name: 'table1') } + let(:model2) { double(connection: double, table_name: 'table2') } + + before do + allow(model1.connection).to receive_message_chain('pool.db_config.name').and_return('name1') + allow(model2.connection).to receive_message_chain('pool.db_config.name').and_return('name2') + end + + it 'yields each model after connecting SharedModel' do + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model1.connection).and_yield + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model2.connection).and_yield + + yielded_models = [] + + described_class.each_model_connection([model1, model2]) do |model, name| + yielded_models << [model, name] + end + + expect(yielded_models).to match_array([[model1, 'name1'], [model2, 'name2']]) + end + end +end diff --git a/spec/lib/gitlab/database/gitlab_schema_spec.rb b/spec/lib/gitlab/database/gitlab_schema_spec.rb new file mode 100644 index 00000000000..255efc99ff6 --- /dev/null +++ b/spec/lib/gitlab/database/gitlab_schema_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Database::GitlabSchema do + describe '.tables_to_schema' do + subject { described_class.tables_to_schema } + + it 'all tables have assigned a known gitlab_schema' do + is_expected.to all( + match([be_a(String), be_in([:gitlab_shared, :gitlab_main, :gitlab_ci])]) + ) + end + + # This being run across different databases indirectly also tests + # a general consistency of structure across databases + Gitlab::Database.database_base_models.each do |db_config_name, db_class| + let(:db_data_sources) { db_class.connection.data_sources } + + context "for #{db_config_name} using #{db_class}" do + it 'new data sources are added' do + missing_tables = db_data_sources.to_set - subject.keys + + expect(missing_tables).to be_empty, \ + "Missing table(s) #{missing_tables.to_a} not found in #{described_class}.tables_to_schema. " \ + "Any new tables must be added to lib/gitlab/database/gitlab_schemas.yml." + end + + it 'non-existing data sources are removed' do + extra_tables = subject.keys.to_set - db_data_sources + + expect(extra_tables).to be_empty, \ + "Extra table(s) #{extra_tables.to_a} found in #{described_class}.tables_to_schema. " \ + "Any removed or renamed tables must be removed from lib/gitlab/database/gitlab_schemas.yml." + end + end + end + end + + describe '.table_schema' do + using RSpec::Parameterized::TableSyntax + + where(:name, :classification) do + 'ci_builds' | :gitlab_ci + 'my_schema.ci_builds' | :gitlab_ci + 'information_schema.columns' | :gitlab_shared + 'audit_events_part_5fc467ac26' | :gitlab_main + '_test_my_table' | :gitlab_shared + 'pg_attribute' | :gitlab_shared + 'my_other_table' | :undefined_my_other_table + end + + with_them do + subject { described_class.table_schema(name) } + + it { is_expected.to eq(classification) } + end + end +end diff --git a/spec/lib/gitlab/database/load_balancing/configuration_spec.rb b/spec/lib/gitlab/database/load_balancing/configuration_spec.rb index 3e5249a3dea..eef248afdf2 100644 --- a/spec/lib/gitlab/database/load_balancing/configuration_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/configuration_spec.rb @@ -3,17 +3,12 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::Configuration do - let(:model) do - config = ActiveRecord::DatabaseConfigurations::HashConfig - .new('main', 'test', configuration_hash) - - double(:model, connection_db_config: config) - end + let(:configuration_hash) { {} } + let(:db_config) { ActiveRecord::DatabaseConfigurations::HashConfig.new('test', 'ci', configuration_hash) } + let(:model) { double(:model, connection_db_config: db_config) } describe '.for_model' do context 'when load balancing is not configured' do - let(:configuration_hash) { {} } - it 'uses the default settings' do config = described_class.for_model(model) @@ -105,6 +100,14 @@ RSpec.describe Gitlab::Database::LoadBalancing::Configuration do expect(config.pool_size).to eq(4) end end + + it 'calls reuse_primary_connection!' do + expect_next_instance_of(described_class) do |subject| + expect(subject).to receive(:reuse_primary_connection!).and_call_original + end + + described_class.for_model(model) + end end describe '#load_balancing_enabled?' do @@ -180,4 +183,60 @@ RSpec.describe Gitlab::Database::LoadBalancing::Configuration do end end end + + describe '#db_config_name' do + let(:config) { described_class.new(model) } + + subject { config.db_config_name } + + it 'returns connection name as symbol' do + is_expected.to eq(:ci) + end + end + + describe '#replica_db_config' do + let(:model) { double(:model, connection_db_config: db_config, connection_specification_name: 'Ci::ApplicationRecord') } + let(:config) { described_class.for_model(model) } + + it 'returns exactly db_config' do + expect(config.replica_db_config).to eq(db_config) + end + + context 'when GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci=main' do + it 'does not change replica_db_config' do + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main') + + expect(config.replica_db_config).to eq(db_config) + end + end + end + + describe 'reuse_primary_connection!' do + let(:model) { double(:model, connection_db_config: db_config, connection_specification_name: 'Ci::ApplicationRecord') } + let(:config) { described_class.for_model(model) } + + context 'when GITLAB_LOAD_BALANCING_REUSE_PRIMARY_* not configured' do + it 'the primary connection uses default specification' do + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil) + + expect(config.primary_connection_specification_name).to eq('Ci::ApplicationRecord') + end + end + + context 'when GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci=main' do + it 'the primary connection uses main connection' do + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main') + + expect(config.primary_connection_specification_name).to eq('ActiveRecord::Base') + end + end + + context 'when GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci=unknown' do + it 'raises exception' do + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'unknown') + + expect { config.reuse_primary_connection! }.to raise_error /Invalid value for/ + end + end + end end diff --git a/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb b/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb index ba2f9485066..ee2718171c0 100644 --- a/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb @@ -3,12 +3,9 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do - let(:proxy) do - config = Gitlab::Database::LoadBalancing::Configuration - .new(ActiveRecord::Base) - - described_class.new(Gitlab::Database::LoadBalancing::LoadBalancer.new(config)) - end + let(:config) { Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base) } + let(:load_balancer) { Gitlab::Database::LoadBalancing::LoadBalancer.new(config) } + let(:proxy) { described_class.new(load_balancer) } describe '#select' do it 'performs a read' do @@ -85,7 +82,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do describe '.insert_all!' do before do ActiveRecord::Schema.define do - create_table :connection_proxy_bulk_insert, force: true do |t| + create_table :_test_connection_proxy_bulk_insert, force: true do |t| t.string :name, null: true end end @@ -93,13 +90,13 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do after do ActiveRecord::Schema.define do - drop_table :connection_proxy_bulk_insert, force: true + drop_table :_test_connection_proxy_bulk_insert, force: true end end let(:model_class) do Class.new(ApplicationRecord) do - self.table_name = "connection_proxy_bulk_insert" + self.table_name = "_test_connection_proxy_bulk_insert" end end @@ -143,9 +140,9 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do context 'with a read query' do it 'runs the transaction and any nested queries on the replica' do - expect(proxy.load_balancer).to receive(:read) + expect(load_balancer).to receive(:read) .twice.and_yield(replica) - expect(proxy.load_balancer).not_to receive(:read_write) + expect(load_balancer).not_to receive(:read_write) expect(session).not_to receive(:write!) proxy.transaction { proxy.select('true') } @@ -154,8 +151,8 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do context 'with a write query' do it 'raises an exception' do - allow(proxy.load_balancer).to receive(:read).and_yield(replica) - allow(proxy.load_balancer).to receive(:read_write).and_yield(replica) + allow(load_balancer).to receive(:read).and_yield(replica) + allow(load_balancer).to receive(:read_write).and_yield(replica) expect do proxy.transaction { proxy.insert('something') } @@ -178,9 +175,9 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do context 'with a read query' do it 'runs the transaction and any nested queries on the primary and stick to it' do - expect(proxy.load_balancer).to receive(:read_write) + expect(load_balancer).to receive(:read_write) .twice.and_yield(primary) - expect(proxy.load_balancer).not_to receive(:read) + expect(load_balancer).not_to receive(:read) expect(session).to receive(:write!) proxy.transaction { proxy.select('true') } @@ -189,9 +186,9 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do context 'with a write query' do it 'runs the transaction and any nested queries on the primary and stick to it' do - expect(proxy.load_balancer).to receive(:read_write) + expect(load_balancer).to receive(:read_write) .twice.and_yield(primary) - expect(proxy.load_balancer).not_to receive(:read) + expect(load_balancer).not_to receive(:read) expect(session).to receive(:write!).twice proxy.transaction { proxy.insert('something') } @@ -209,7 +206,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do end it 'properly forwards keyword arguments' do - allow(proxy.load_balancer).to receive(:read_write) + allow(load_balancer).to receive(:read_write) expect(proxy).to receive(:write_using_load_balancer).and_call_original @@ -234,7 +231,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do end it 'properly forwards keyword arguments' do - allow(proxy.load_balancer).to receive(:read) + allow(load_balancer).to receive(:read) expect(proxy).to receive(:read_using_load_balancer).and_call_original @@ -259,7 +256,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do allow(session).to receive(:use_replicas_for_read_queries?).and_return(false) expect(connection).to receive(:foo).with('foo') - expect(proxy.load_balancer).to receive(:read).and_yield(connection) + expect(load_balancer).to receive(:read).and_yield(connection) proxy.read_using_load_balancer(:foo, 'foo') end @@ -271,7 +268,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do allow(session).to receive(:use_replicas_for_read_queries?).and_return(true) expect(connection).to receive(:foo).with('foo') - expect(proxy.load_balancer).to receive(:read).and_yield(connection) + expect(load_balancer).to receive(:read).and_yield(connection) proxy.read_using_load_balancer(:foo, 'foo') end @@ -283,7 +280,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do allow(session).to receive(:use_replicas_for_read_queries?).and_return(true) expect(connection).to receive(:foo).with('foo') - expect(proxy.load_balancer).to receive(:read).and_yield(connection) + expect(load_balancer).to receive(:read).and_yield(connection) proxy.read_using_load_balancer(:foo, 'foo') end @@ -296,7 +293,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do expect(connection).to receive(:foo).with('foo') - expect(proxy.load_balancer).to receive(:read_write) + expect(load_balancer).to receive(:read_write) .and_yield(connection) proxy.read_using_load_balancer(:foo, 'foo') @@ -314,7 +311,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do end it 'uses but does not stick to the primary' do - expect(proxy.load_balancer).to receive(:read_write).and_yield(connection) + expect(load_balancer).to receive(:read_write).and_yield(connection) expect(connection).to receive(:foo).with('foo') expect(session).not_to receive(:write!) diff --git a/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb b/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb index f824d4cefdf..37b83729125 100644 --- a/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb @@ -4,10 +4,11 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do let(:conflict_error) { Class.new(RuntimeError) } - let(:db_host) { ActiveRecord::Base.connection_pool.db_config.host } + let(:model) { ActiveRecord::Base } + let(:db_host) { model.connection_pool.db_config.host } let(:config) do Gitlab::Database::LoadBalancing::Configuration - .new(ActiveRecord::Base, [db_host, db_host]) + .new(model, [db_host, db_host]) end let(:lb) { described_class.new(config) } @@ -88,6 +89,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do host = double(:host) allow(lb).to receive(:host).and_return(host) + allow(Rails.application.executor).to receive(:active?).and_return(true) allow(host).to receive(:query_cache_enabled).and_return(false) allow(host).to receive(:connection).and_return(connection) @@ -96,6 +98,20 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do lb.read { 10 } end + it 'does not enable query cache when outside Rails executor context' do + connection = double(:connection) + host = double(:host) + + allow(lb).to receive(:host).and_return(host) + allow(Rails.application.executor).to receive(:active?).and_return(false) + allow(host).to receive(:query_cache_enabled).and_return(false) + allow(host).to receive(:connection).and_return(connection) + + expect(host).not_to receive(:enable_query_cache!) + + lb.read { 10 } + end + it 'marks hosts that are offline' do allow(lb).to receive(:connection_error?).and_return(true) @@ -216,7 +232,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do it 'does not create conflicts with other load balancers when caching hosts' do ci_config = Gitlab::Database::LoadBalancing::Configuration - .new(Ci::CiDatabaseRecord, [db_host, db_host]) + .new(Ci::ApplicationRecord, [db_host, db_host]) lb1 = described_class.new(config) lb2 = described_class.new(ci_config) @@ -459,4 +475,84 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do lb.disconnect!(timeout: 30) end end + + describe '#get_write_location' do + it 'returns a string' do + expect(lb.send(:get_write_location, lb.pool.connection)) + .to be_a(String) + end + + it 'returns nil if there are no results' do + expect(lb.send(:get_write_location, double(select_all: []))).to be_nil + end + end + + describe 'primary connection re-use', :reestablished_active_record_base do + let(:model) { Ci::ApplicationRecord } + + around do |example| + if Gitlab::Database.has_config?(:ci) + example.run + else + # fake additional Database + model.establish_connection( + ActiveRecord::DatabaseConfigurations::HashConfig.new(Rails.env, 'ci', ActiveRecord::Base.connection_db_config.configuration_hash) + ) + + example.run + + # Cleanup connection_specification_name for Ci::ApplicationRecord + model.remove_connection + end + end + + describe '#read' do + it 'returns ci replica connection' do + expect { |b| lb.read(&b) }.to yield_with_args do |args| + expect(args.pool.db_config.name).to eq('ci_replica') + end + end + + context 'when GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci=main' do + it 'returns ci replica connection' do + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main') + + expect { |b| lb.read(&b) }.to yield_with_args do |args| + expect(args.pool.db_config.name).to eq('ci_replica') + end + end + end + end + + describe '#read_write' do + it 'returns Ci::ApplicationRecord connection' do + expect { |b| lb.read_write(&b) }.to yield_with_args do |args| + expect(args.pool.db_config.name).to eq('ci') + end + end + + context 'when GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci=main' do + it 'returns ActiveRecord::Base connection' do + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main') + + expect { |b| lb.read_write(&b) }.to yield_with_args do |args| + expect(args.pool.db_config.name).to eq('main') + end + end + end + end + end + + describe '#wal_diff' do + it 'returns the diff between two write locations' do + loc1 = lb.send(:get_write_location, lb.pool.connection) + + create(:user) # This ensures we get a new WAL location + + loc2 = lb.send(:get_write_location, lb.pool.connection) + diff = lb.wal_diff(loc2, loc1) + + expect(diff).to be_positive + end + end end diff --git a/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb b/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb index 45d81808971..02c9499bedb 100644 --- a/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb @@ -51,7 +51,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::PrimaryHost do end describe '#offline!' do - it 'does nothing' do + it 'logs the event but does nothing else' do + expect(Gitlab::Database::LoadBalancing::Logger).to receive(:warn) + .with(hash_including(event: :host_offline)) + .and_call_original + expect(host.offline!).to be_nil end end diff --git a/spec/lib/gitlab/database/load_balancing/rack_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/rack_middleware_spec.rb index af7e2a4b167..b768d4ecea3 100644 --- a/spec/lib/gitlab/database/load_balancing/rack_middleware_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/rack_middleware_spec.rb @@ -6,12 +6,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do let(:app) { double(:app) } let(:middleware) { described_class.new(app) } let(:warden_user) { double(:warden, user: double(:user, id: 42)) } - let(:single_sticking_object) { Set.new([[ActiveRecord::Base, :user, 42]]) } + let(:single_sticking_object) { Set.new([[ActiveRecord::Base.sticking, :user, 42]]) } let(:multiple_sticking_objects) do Set.new([ - [ActiveRecord::Base, :user, 42], - [ActiveRecord::Base, :runner, '123456789'], - [ActiveRecord::Base, :runner, '1234'] + [ActiveRecord::Base.sticking, :user, 42], + [ActiveRecord::Base.sticking, :runner, '123456789'], + [ActiveRecord::Base.sticking, :runner, '1234'] ]) end @@ -162,7 +162,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do it 'returns the warden user if present' do env = { 'warden' => warden_user } ids = Gitlab::Database::LoadBalancing.base_models.map do |model| - [model, :user, 42] + [model.sticking, :user, 42] end expect(middleware.sticking_namespaces(env)).to eq(ids) @@ -181,9 +181,9 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do env = { described_class::STICK_OBJECT => multiple_sticking_objects } expect(middleware.sticking_namespaces(env)).to eq([ - [ActiveRecord::Base, :user, 42], - [ActiveRecord::Base, :runner, '123456789'], - [ActiveRecord::Base, :runner, '1234'] + [ActiveRecord::Base.sticking, :user, 42], + [ActiveRecord::Base.sticking, :runner, '123456789'], + [ActiveRecord::Base.sticking, :runner, '1234'] ]) end end diff --git a/spec/lib/gitlab/database/load_balancing/setup_spec.rb b/spec/lib/gitlab/database/load_balancing/setup_spec.rb index 01646bc76ef..953d83d3b48 100644 --- a/spec/lib/gitlab/database/load_balancing/setup_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/setup_spec.rb @@ -7,19 +7,20 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do it 'sets up the load balancer' do setup = described_class.new(ActiveRecord::Base) - expect(setup).to receive(:disable_prepared_statements) - expect(setup).to receive(:setup_load_balancer) + 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 end - describe '#disable_prepared_statements' do - it 'disables prepared statements and reconnects to the database' do + describe '#configure_connection' do + it 'configures pool, prepared statements and reconnects to the database' do config = double( :config, - configuration_hash: { host: 'localhost' }, + configuration_hash: { host: 'localhost', pool: 2, prepared_statements: true }, env_name: 'test', name: 'main' ) @@ -27,7 +28,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do expect(ActiveRecord::DatabaseConfigurations::HashConfig) .to receive(:new) - .with('test', 'main', { host: 'localhost', prepared_statements: false }) + .with('test', 'main', { + host: 'localhost', + prepared_statements: false, + pool: Gitlab::Database.default_pool_size + }) .and_call_original # HashConfig doesn't implement its own #==, so we can't directly compare @@ -36,11 +41,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do .to receive(:establish_connection) .with(an_instance_of(ActiveRecord::DatabaseConfigurations::HashConfig)) - described_class.new(model).disable_prepared_statements + described_class.new(model).configure_connection end end - describe '#setup_load_balancer' do + describe '#setup_connection_proxy' do it 'sets up the load balancer' do model = Class.new(ActiveRecord::Base) setup = described_class.new(model) @@ -54,9 +59,9 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do .with(setup.configuration) .and_return(lb) - setup.setup_load_balancer + setup.setup_connection_proxy - expect(model.connection.load_balancer).to eq(lb) + expect(model.load_balancer).to eq(lb) expect(model.sticking) .to be_an_instance_of(Gitlab::Database::LoadBalancing::Sticking) end @@ -77,7 +82,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do model = ActiveRecord::Base setup = described_class.new(model) sv = instance_spy(Gitlab::Database::LoadBalancing::ServiceDiscovery) - lb = model.connection.load_balancer allow(setup.configuration) .to receive(:service_discovery_enabled?) @@ -85,7 +89,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do allow(Gitlab::Database::LoadBalancing::ServiceDiscovery) .to receive(:new) - .with(lb, setup.configuration.service_discovery) + .with(setup.load_balancer, setup.configuration.service_discovery) .and_return(sv) expect(sv).to receive(:perform_service_discovery) @@ -98,7 +102,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do model = ActiveRecord::Base setup = described_class.new(model, start_service_discovery: true) sv = instance_spy(Gitlab::Database::LoadBalancing::ServiceDiscovery) - lb = model.connection.load_balancer allow(setup.configuration) .to receive(:service_discovery_enabled?) @@ -106,7 +109,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do allow(Gitlab::Database::LoadBalancing::ServiceDiscovery) .to receive(:new) - .with(lb, setup.configuration.service_discovery) + .with(setup.load_balancer, setup.configuration.service_discovery) .and_return(sv) expect(sv).to receive(:perform_service_discovery) @@ -116,4 +119,181 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do end end end + + describe '#setup_feature_flag_to_model_load_balancing', :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', + env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, + request_store_active: false, + ff_use_model_load_balancing: nil, + 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', + env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', + request_store_active: false, + ff_use_model_load_balancing: nil, + 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, + 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', + env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', + request_store_active: false, + ff_use_model_load_balancing: nil, + expectations: { + main: { read: 'main_replica', write: 'main' }, + ci: { read: 'main_replica', write: 'main' } + } + }, + "with FF 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, + expectations: { + main: { read: 'main_replica', write: 'main' }, + ci: { read: 'main_replica', write: 'main' } + } + }, + "with FF 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, + expectations: { + main: { read: 'main_replica', write: 'main' }, + ci: { read: 'main_replica', write: 'main' } + } + }, + "with FF 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, + expectations: { + main: { read: 'main_replica', write: 'main' }, + ci: { read: 'main_replica', write: 'main' } + } + }, + "with FF 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, + expectations: { + main: { read: 'main_replica', write: 'main' }, + ci: { read: 'ci_replica', write: 'ci' } + } + }, + "with re-use and FF 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, + expectations: { + main: { read: 'main_replica', write: 'main' }, + ci: { read: 'ci_replica', write: 'main' } + } + } + } + end + + with_them do + let(:ci_class) do + Class.new(ActiveRecord::Base) do + def self.name + 'Ci::ApplicationRecordTemporary' + end + + establish_connection ActiveRecord::DatabaseConfigurations::HashConfig.new( + Rails.env, + 'ci', + ActiveRecord::Base.connection_db_config.configuration_hash + ) + end + end + + let(:models) do + { + main: ActiveRecord::Base, + ci: ci_class + } + end + + around do |example| + if request_store_active + Gitlab::WithRequestStore.with_request_store do + RequestStore.clear! + + example.run + end + else + example.run + end + end + + before do + # Rewrite `class_attribute` to use rspec mocking and prevent modifying the objects + allow_next_instance_of(described_class) do |setup| + allow(setup).to receive(:configure_connection) + + allow(setup).to receive(:setup_class_attribute) do |attribute, value| + allow(setup.model).to receive(attribute) { value } + 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| + described_class.new(model).tap do |subject| + subject.configuration.hosts = [subject.configuration.replica_db_config.host] + subject.setup + end + end + end + + it 'results match expectations' do + result = models.transform_values do |model| + load_balancer = model.connection.instance_variable_get(:@load_balancer) + + { + read: load_balancer.read { |connection| connection.pool.db_config.name }, + write: load_balancer.read_write { |connection| connection.pool.db_config.name } + } + end + + expect(result).to eq(expectations) + end + + it 'does return load_balancer assigned to a given connection' do + models.each do |name, model| + expect(model.load_balancer.name).to eq(name) + expect(model.sticking.instance_variable_get(:@load_balancer)).to eq(model.load_balancer) + end + end + end + end end diff --git a/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb index 08dd6a0a788..9acf80e684f 100644 --- a/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb @@ -181,11 +181,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do end context 'when worker data consistency is :delayed' do - include_examples 'mark data consistency location', :delayed + include_examples 'mark data consistency location', :delayed end context 'when worker data consistency is :sticky' do - include_examples 'mark data consistency location', :sticky + include_examples 'mark data consistency location', :sticky end end end diff --git a/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb index 06efdcd8f99..de2ad662d16 100644 --- a/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb @@ -64,7 +64,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_ let(:wal_locations) { { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } } it 'does not stick to the primary', :aggregate_failures do - expect(ActiveRecord::Base.connection.load_balancer) + expect(ActiveRecord::Base.load_balancer) .to receive(:select_up_to_date_host) .with(location) .and_return(true) @@ -107,7 +107,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_ let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'dedup_wal_locations' => wal_locations } } before do - allow(ActiveRecord::Base.connection.load_balancer) + allow(ActiveRecord::Base.load_balancer) .to receive(:select_up_to_date_host) .with(wal_locations[:main]) .and_return(true) @@ -120,7 +120,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_ let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'database_write_location' => '0/D525E3A8' } } before do - allow(ActiveRecord::Base.connection.load_balancer) + allow(ActiveRecord::Base.load_balancer) .to receive(:select_up_to_date_host) .with('0/D525E3A8') .and_return(true) diff --git a/spec/lib/gitlab/database/load_balancing/sticking_spec.rb b/spec/lib/gitlab/database/load_balancing/sticking_spec.rb index 8ceda52ee85..d88554614cf 100644 --- a/spec/lib/gitlab/database/load_balancing/sticking_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/sticking_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do let(:sticking) do - described_class.new(ActiveRecord::Base.connection.load_balancer) + described_class.new(ActiveRecord::Base.load_balancer) end after do @@ -22,7 +22,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do sticking.stick_or_unstick_request(env, :user, 42) expect(env[Gitlab::Database::LoadBalancing::RackMiddleware::STICK_OBJECT].to_a) - .to eq([[ActiveRecord::Base, :user, 42]]) + .to eq([[sticking, :user, 42]]) end it 'sticks or unsticks multiple objects and updates the Rack environment' do @@ -42,8 +42,8 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do sticking.stick_or_unstick_request(env, :runner, '123456789') expect(env[Gitlab::Database::LoadBalancing::RackMiddleware::STICK_OBJECT].to_a).to eq([ - [ActiveRecord::Base, :user, 42], - [ActiveRecord::Base, :runner, '123456789'] + [sticking, :user, 42], + [sticking, :runner, '123456789'] ]) end end @@ -73,7 +73,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do end describe '#all_caught_up?' do - let(:lb) { ActiveRecord::Base.connection.load_balancer } + let(:lb) { ActiveRecord::Base.load_balancer } let(:last_write_location) { 'foo' } before do @@ -137,7 +137,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do end describe '#unstick_or_continue_sticking' do - let(:lb) { ActiveRecord::Base.connection.load_balancer } + let(:lb) { ActiveRecord::Base.load_balancer } it 'simply returns if no write location could be found' do allow(sticking) @@ -182,13 +182,13 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do RSpec.shared_examples 'sticking' do before do - allow(ActiveRecord::Base.connection.load_balancer) + allow(ActiveRecord::Base.load_balancer) .to receive(:primary_write_location) .and_return('foo') end it 'sticks an entity to the primary', :aggregate_failures do - allow(ActiveRecord::Base.connection.load_balancer) + allow(ActiveRecord::Base.load_balancer) .to receive(:primary_only?) .and_return(false) @@ -227,11 +227,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do describe '#mark_primary_write_location' do it 'updates the write location with the load balancer' do - allow(ActiveRecord::Base.connection.load_balancer) + allow(ActiveRecord::Base.load_balancer) .to receive(:primary_write_location) .and_return('foo') - allow(ActiveRecord::Base.connection.load_balancer) + allow(ActiveRecord::Base.load_balancer) .to receive(:primary_only?) .and_return(false) @@ -291,7 +291,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do end describe '#select_caught_up_replicas' do - let(:lb) { ActiveRecord::Base.connection.load_balancer } + let(:lb) { ActiveRecord::Base.load_balancer } context 'with no write location' do before do diff --git a/spec/lib/gitlab/database/load_balancing_spec.rb b/spec/lib/gitlab/database/load_balancing_spec.rb index bf5314e2c34..65ffe539910 100644 --- a/spec/lib/gitlab/database/load_balancing_spec.rb +++ b/spec/lib/gitlab/database/load_balancing_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Gitlab::Database::LoadBalancing do expect(models).to include(ActiveRecord::Base) if Gitlab::Database.has_config?(:ci) - expect(models).to include(Ci::CiDatabaseRecord) + expect(models).to include(Ci::ApplicationRecord) end end @@ -76,7 +76,7 @@ RSpec.describe Gitlab::Database::LoadBalancing do context 'when a read connection is used' do it 'returns :replica' do - proxy.load_balancer.read do |connection| + load_balancer.read do |connection| expect(described_class.db_role_for_connection(connection)).to eq(:replica) end end @@ -84,7 +84,7 @@ RSpec.describe Gitlab::Database::LoadBalancing do context 'when a read_write connection is used' do it 'returns :primary' do - proxy.load_balancer.read_write do |connection| + load_balancer.read_write do |connection| expect(described_class.db_role_for_connection(connection)).to eq(:primary) end end @@ -105,7 +105,7 @@ RSpec.describe Gitlab::Database::LoadBalancing do describe 'LoadBalancing integration tests', :database_replica, :delete do before(:all) do ActiveRecord::Schema.define do - create_table :load_balancing_test, force: true do |t| + create_table :_test_load_balancing_test, force: true do |t| t.string :name, null: true end end @@ -113,13 +113,13 @@ RSpec.describe Gitlab::Database::LoadBalancing do after(:all) do ActiveRecord::Schema.define do - drop_table :load_balancing_test, force: true + drop_table :_test_load_balancing_test, force: true end end let(:model) do Class.new(ApplicationRecord) do - self.table_name = "load_balancing_test" + self.table_name = "_test_load_balancing_test" end end @@ -443,7 +443,7 @@ RSpec.describe Gitlab::Database::LoadBalancing do elsif payload[:name] == 'SQL' # Custom query true else - keywords = %w[load_balancing_test] + keywords = %w[_test_load_balancing_test] keywords += %w[begin commit] if include_transaction keywords.any? { |keyword| payload[:sql].downcase.include?(keyword) } end diff --git a/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb index 54b3ad22faf..f1dbfbbff18 100644 --- a/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb @@ -9,18 +9,18 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do let(:model) do Class.new(ApplicationRecord) do - self.table_name = 'loose_fk_test_table' + self.table_name = '_test_loose_fk_test_table' end end before(:all) do - migration.create_table :loose_fk_test_table do |t| + migration.create_table :_test_loose_fk_test_table do |t| t.timestamps end end after(:all) do - migration.drop_table :loose_fk_test_table + migration.drop_table :_test_loose_fk_test_table end before do @@ -37,7 +37,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do context 'when the record deletion tracker trigger is installed' do before do - migration.track_record_deletions(:loose_fk_test_table) + migration.track_record_deletions(:_test_loose_fk_test_table) end it 'stores the record deletion' do @@ -50,7 +50,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do deleted_record = LooseForeignKeys::DeletedRecord.all.first expect(deleted_record.primary_key_value).to eq(record_to_be_deleted.id) - expect(deleted_record.fully_qualified_table_name).to eq('public.loose_fk_test_table') + expect(deleted_record.fully_qualified_table_name).to eq('public._test_loose_fk_test_table') expect(deleted_record.partition).to eq(1) end diff --git a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb index 854e97ef897..acf775b3538 100644 --- a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do let(:model) { Class.new(ActiveRecord::Base) } before do - model.table_name = :test_table + model.table_name = :_test_table end context 'when called inside a transaction block' do @@ -30,19 +30,19 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do it 'raises an error' do expect do - migration.public_send(operation, :test_table, :original, :renamed) + migration.public_send(operation, :_test_table, :original, :renamed) end.to raise_error("#{operation} can not be run inside a transaction") end end context 'when the existing column has a default value' do before do - migration.change_column_default :test_table, existing_column, 'default value' + migration.change_column_default :_test_table, existing_column, 'default value' end it 'raises an error' do expect do - migration.public_send(operation, :test_table, :original, :renamed) + migration.public_send(operation, :_test_table, :original, :renamed) end.to raise_error("#{operation} does not currently support columns with default values") end end @@ -51,18 +51,18 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do context 'when the batch column does not exist' do it 'raises an error' do expect do - migration.public_send(operation, :test_table, :original, :renamed, batch_column_name: :missing) - end.to raise_error('Column missing does not exist on test_table') + migration.public_send(operation, :_test_table, :original, :renamed, batch_column_name: :missing) + end.to raise_error('Column missing does not exist on _test_table') end end context 'when the batch column does exist' do it 'passes it when creating the column' do expect(migration).to receive(:create_column_from) - .with(:test_table, existing_column, added_column, type: nil, batch_column_name: :status) + .with(:_test_table, existing_column, added_column, type: nil, batch_column_name: :status) .and_call_original - migration.public_send(operation, :test_table, :original, :renamed, batch_column_name: :status) + migration.public_send(operation, :_test_table, :original, :renamed, batch_column_name: :status) end end end @@ -71,17 +71,17 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do existing_record_1 = model.create!(status: 0, existing_column => 'existing') existing_record_2 = model.create!(status: 0, existing_column => nil) - migration.send(operation, :test_table, :original, :renamed) + migration.send(operation, :_test_table, :original, :renamed) model.reset_column_information - expect(migration.column_exists?(:test_table, added_column)).to eq(true) + expect(migration.column_exists?(:_test_table, added_column)).to eq(true) expect(existing_record_1.reload).to have_attributes(status: 0, original: 'existing', renamed: 'existing') expect(existing_record_2.reload).to have_attributes(status: 0, original: nil, renamed: nil) end it 'installs triggers to sync new data' do - migration.public_send(operation, :test_table, :original, :renamed) + migration.public_send(operation, :_test_table, :original, :renamed) model.reset_column_information new_record_1 = model.create!(status: 1, original: 'first') @@ -102,7 +102,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do before do allow(migration).to receive(:transaction_open?).and_return(false) - migration.create_table :test_table do |t| + migration.create_table :_test_table do |t| t.integer :status, null: false t.text :original t.text :other_column @@ -118,8 +118,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do context 'when the column to rename does not exist' do it 'raises an error' do expect do - migration.rename_column_concurrently :test_table, :missing_column, :renamed - end.to raise_error('Column missing_column does not exist on test_table') + migration.rename_column_concurrently :_test_table, :missing_column, :renamed + end.to raise_error('Column missing_column does not exist on _test_table') end end end @@ -128,7 +128,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do before do allow(migration).to receive(:transaction_open?).and_return(false) - migration.create_table :test_table do |t| + migration.create_table :_test_table do |t| t.integer :status, null: false t.text :other_column t.text :renamed @@ -144,8 +144,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do context 'when the renamed column does not exist' do it 'raises an error' do expect do - migration.undo_cleanup_concurrent_column_rename :test_table, :original, :missing_column - end.to raise_error('Column missing_column does not exist on test_table') + migration.undo_cleanup_concurrent_column_rename :_test_table, :original, :missing_column + end.to raise_error('Column missing_column does not exist on _test_table') end end end @@ -156,25 +156,25 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do before do allow(migration).to receive(:transaction_open?).and_return(false) - migration.create_table :test_table do |t| + migration.create_table :_test_table do |t| t.integer :status, null: false t.text :original t.text :other_column end - migration.rename_column_concurrently :test_table, :original, :renamed + migration.rename_column_concurrently :_test_table, :original, :renamed end context 'when the helper is called repeatedly' do before do - migration.public_send(operation, :test_table, :original, :renamed) + migration.public_send(operation, :_test_table, :original, :renamed) end it 'does not make repeated attempts to cleanup' do expect(migration).not_to receive(:remove_column) expect do - migration.public_send(operation, :test_table, :original, :renamed) + migration.public_send(operation, :_test_table, :original, :renamed) end.not_to raise_error end end @@ -182,26 +182,26 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do context 'when the renamed column exists' do let(:triggers) do [ - ['trigger_7cc71f92fd63', 'function_for_trigger_7cc71f92fd63', before: 'insert'], - ['trigger_f1a1f619636a', 'function_for_trigger_f1a1f619636a', before: 'update'], - ['trigger_769a49938884', 'function_for_trigger_769a49938884', before: 'update'] + ['trigger_020dbcb8cdd0', 'function_for_trigger_020dbcb8cdd0', before: 'insert'], + ['trigger_6edaca641d03', 'function_for_trigger_6edaca641d03', before: 'update'], + ['trigger_a3fb9f3add34', 'function_for_trigger_a3fb9f3add34', before: 'update'] ] end it 'removes the sync triggers and renamed columns' do triggers.each do |(trigger_name, function_name, event)| expect_function_to_exist(function_name) - expect_valid_function_trigger(:test_table, trigger_name, function_name, event) + expect_valid_function_trigger(:_test_table, trigger_name, function_name, event) end - expect(migration.column_exists?(:test_table, added_column)).to eq(true) + expect(migration.column_exists?(:_test_table, added_column)).to eq(true) - migration.public_send(operation, :test_table, :original, :renamed) + migration.public_send(operation, :_test_table, :original, :renamed) - expect(migration.column_exists?(:test_table, added_column)).to eq(false) + expect(migration.column_exists?(:_test_table, added_column)).to eq(false) triggers.each do |(trigger_name, function_name, _)| - expect_trigger_not_to_exist(:test_table, trigger_name) + expect_trigger_not_to_exist(:_test_table, trigger_name) expect_function_not_to_exist(function_name) end end @@ -223,7 +223,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do end describe '#create_table' do - let(:table_name) { :test_table } + let(:table_name) { :_test_table } let(:column_attributes) do [ { name: 'id', sql_type: 'bigint', null: false, default: nil }, @@ -245,7 +245,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do end expect_table_columns_to_match(column_attributes, table_name) - expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 100') + expect_check_constraint(table_name, 'check_e9982cf9da', 'char_length(name) <= 100') end end end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index d89af1521a2..ea755f5a368 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -31,16 +31,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end describe '#add_timestamps_with_timezone' do - let(:in_transaction) { false } - - before do - allow(model).to receive(:transaction_open?).and_return(in_transaction) - allow(model).to receive(:disable_statement_timeout) - end - it 'adds "created_at" and "updated_at" fields with the "datetime_with_timezone" data type' do Gitlab::Database::MigrationHelpers::DEFAULT_TIMESTAMP_COLUMNS.each do |column_name| - expect(model).to receive(:add_column).with(:foo, column_name, :datetime_with_timezone, { null: false }) + expect(model).to receive(:add_column) + .with(:foo, column_name, :datetime_with_timezone, { default: nil, null: false }) end model.add_timestamps_with_timezone(:foo) @@ -48,7 +42,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do it 'can disable the NOT NULL constraint' do Gitlab::Database::MigrationHelpers::DEFAULT_TIMESTAMP_COLUMNS.each do |column_name| - expect(model).to receive(:add_column).with(:foo, column_name, :datetime_with_timezone, { null: true }) + expect(model).to receive(:add_column) + .with(:foo, column_name, :datetime_with_timezone, { default: nil, null: true }) end model.add_timestamps_with_timezone(:foo, null: true) @@ -64,9 +59,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do it 'can add choice of acceptable columns' do expect(model).to receive(:add_column).with(:foo, :created_at, :datetime_with_timezone, anything) expect(model).to receive(:add_column).with(:foo, :deleted_at, :datetime_with_timezone, anything) + expect(model).to receive(:add_column).with(:foo, :processed_at, :datetime_with_timezone, anything) expect(model).not_to receive(:add_column).with(:foo, :updated_at, :datetime_with_timezone, anything) - model.add_timestamps_with_timezone(:foo, columns: [:created_at, :deleted_at]) + model.add_timestamps_with_timezone(:foo, columns: [:created_at, :deleted_at, :processed_at]) end it 'cannot add unacceptable column names' do @@ -74,29 +70,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do model.add_timestamps_with_timezone(:foo, columns: [:bar]) end.to raise_error %r/Illegal timestamp column name/ end - - context 'in a transaction' do - let(:in_transaction) { true } - - before do - allow(model).to receive(:add_column).with(any_args).and_call_original - allow(model).to receive(:add_column) - .with(:foo, anything, :datetime_with_timezone, anything) - .and_return(nil) - end - - it 'cannot add a default value' do - expect do - model.add_timestamps_with_timezone(:foo, default: :i_cause_an_error) - end.to raise_error %r/add_timestamps_with_timezone/ - end - - it 'can add columns without defaults' do - expect do - model.add_timestamps_with_timezone(:foo) - end.not_to raise_error - end - end end describe '#create_table_with_constraints' do @@ -271,12 +244,92 @@ RSpec.describe Gitlab::Database::MigrationHelpers do model.add_concurrent_index(:users, :foo, unique: true) end - it 'does nothing if the index exists already' do - expect(model).to receive(:index_exists?) - .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(true) - expect(model).not_to receive(:add_index) + context 'when the index exists and is valid' do + before do + model.add_index :users, :id, unique: true + end - model.add_concurrent_index(:users, :foo, unique: true) + it 'does leaves the existing index' do + expect(model).to receive(:index_exists?) + .with(:users, :id, { algorithm: :concurrently, unique: true }).and_call_original + + expect(model).not_to receive(:remove_index) + expect(model).not_to receive(:add_index) + + model.add_concurrent_index(:users, :id, unique: true) + end + end + + context 'when an invalid copy of the index exists' do + before do + model.add_index :users, :id, unique: true, name: index_name + + model.connection.execute(<<~SQL) + UPDATE pg_index + SET indisvalid = false + WHERE indexrelid = '#{index_name}'::regclass + SQL + end + + context 'when the default name is used' do + let(:index_name) { model.index_name(:users, :id) } + + it 'drops and recreates the index' do + expect(model).to receive(:index_exists?) + .with(:users, :id, { algorithm: :concurrently, unique: true }).and_call_original + expect(model).to receive(:index_invalid?).with(index_name, schema: nil).and_call_original + + expect(model).to receive(:remove_concurrent_index_by_name).with(:users, index_name) + + expect(model).to receive(:add_index) + .with(:users, :id, { algorithm: :concurrently, unique: true }) + + model.add_concurrent_index(:users, :id, unique: true) + end + end + + context 'when a custom name is used' do + let(:index_name) { 'my_test_index' } + + it 'drops and recreates the index' do + expect(model).to receive(:index_exists?) + .with(:users, :id, { algorithm: :concurrently, unique: true, name: index_name }).and_call_original + expect(model).to receive(:index_invalid?).with(index_name, schema: nil).and_call_original + + expect(model).to receive(:remove_concurrent_index_by_name).with(:users, index_name) + + expect(model).to receive(:add_index) + .with(:users, :id, { algorithm: :concurrently, unique: true, name: index_name }) + + model.add_concurrent_index(:users, :id, unique: true, name: index_name) + end + end + + context 'when a qualified table name is used' do + let(:other_schema) { 'foo_schema' } + let(:index_name) { 'my_test_index' } + let(:table_name) { "#{other_schema}.users" } + + before do + model.connection.execute(<<~SQL) + CREATE SCHEMA #{other_schema}; + ALTER TABLE users SET SCHEMA #{other_schema}; + SQL + end + + it 'drops and recreates the index' do + expect(model).to receive(:index_exists?) + .with(table_name, :id, { algorithm: :concurrently, unique: true, name: index_name }).and_call_original + expect(model).to receive(:index_invalid?).with(index_name, schema: other_schema).and_call_original + + expect(model).to receive(:remove_concurrent_index_by_name).with(table_name, index_name) + + expect(model).to receive(:add_index) + .with(table_name, :id, { algorithm: :concurrently, unique: true, name: index_name }) + + model.add_concurrent_index(table_name, :id, unique: true, name: index_name) + end + end end it 'unprepares the async index creation' do diff --git a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb index 1a7116e75e5..e42a6c970ea 100644 --- a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb @@ -583,12 +583,33 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do end describe '#finalized_background_migration' do - include_context 'background migration job class' + let(:job_coordinator) { Gitlab::BackgroundMigration::JobCoordinator.new(:main, BackgroundMigrationWorker) } + + let!(:job_class_name) { 'TestJob' } + let!(:job_class) { Class.new } + let!(:job_perform_method) do + ->(*arguments) do + Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded( + # Value is 'TestJob' defined by :job_class_name in the let! above. + # Scoping prohibits us from directly referencing job_class_name. + RSpec.current_example.example_group_instance.job_class_name, + arguments + ) + end + end let!(:tracked_pending_job) { create(:background_migration_job, class_name: job_class_name, status: :pending, arguments: [1]) } let!(:tracked_successful_job) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [2]) } before do + job_class.define_method(:perform, job_perform_method) + + allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) + .with(:main).and_return(job_coordinator) + + expect(job_coordinator).to receive(:migration_class_for) + .with(job_class_name).at_least(:once) { job_class } + Sidekiq::Testing.disable! do BackgroundMigrationWorker.perform_async(job_class_name, [1, 2]) BackgroundMigrationWorker.perform_async(job_class_name, [3, 4]) diff --git a/spec/lib/gitlab/database/migrations/observers/transaction_duration_spec.rb b/spec/lib/gitlab/database/migrations/observers/transaction_duration_spec.rb new file mode 100644 index 00000000000..e65f89747c4 --- /dev/null +++ b/spec/lib/gitlab/database/migrations/observers/transaction_duration_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Database::Migrations::Observers::TransactionDuration do + subject(:transaction_duration_observer) { described_class.new(observation, directory_path) } + + let(:observation) { Gitlab::Database::Migrations::Observation.new(migration_version, migration_name) } + let(:directory_path) { Dir.mktmpdir } + let(:log_file) { "#{directory_path}/#{migration_version}_#{migration_name}-transaction-duration.json" } + let(:transaction_duration) { Gitlab::Json.parse(File.read(log_file)) } + let(:migration_version) { 20210422152437 } + let(:migration_name) { 'test' } + + after do + FileUtils.remove_entry(directory_path) + end + + it 'records real and sub transactions duration', :delete do + observe + + entry = transaction_duration[0] + start_time, end_time, transaction_type = entry.values_at('start_time', 'end_time', 'transaction_type') + start_time = DateTime.parse(start_time) + end_time = DateTime.parse(end_time) + + aggregate_failures do + expect(transaction_duration.size).to eq(3) + expect(start_time).to be_before(end_time) + expect(transaction_type).not_to be_nil + end + end + + context 'when there are sub-transactions' do + it 'records transaction duration' do + observe_sub_transaction + + expect(transaction_duration.size).to eq(1) + + entry = transaction_duration[0]['transaction_type'] + + expect(entry).to eql 'sub_transaction' + end + end + + context 'when there are real-transactions' do + it 'records transaction duration', :delete do + observe_real_transaction + + expect(transaction_duration.size).to eq(1) + + entry = transaction_duration[0]['transaction_type'] + + expect(entry).to eql 'real_transaction' + end + end + + private + + def observe + transaction_duration_observer.before + run_transaction + transaction_duration_observer.after + transaction_duration_observer.record + end + + def observe_sub_transaction + transaction_duration_observer.before + run_sub_transactions + transaction_duration_observer.after + transaction_duration_observer.record + end + + def observe_real_transaction + transaction_duration_observer.before + run_real_transactions + transaction_duration_observer.after + transaction_duration_observer.record + end + + def run_real_transactions + ActiveRecord::Base.transaction do + end + end + + def run_sub_transactions + ActiveRecord::Base.transaction(requires_new: true) do + end + end + + def run_transaction + ActiveRecord::Base.connection_pool.with_connection do |connection| + Gitlab::Database::SharedModel.using_connection(connection) do + Gitlab::Database::SharedModel.transaction do + Gitlab::Database::SharedModel.transaction(requires_new: true) do + Gitlab::Database::SharedModel.transaction do + Gitlab::Database::SharedModel.transaction do + Gitlab::Database::SharedModel.transaction(requires_new: true) do + end + end + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb b/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb index 8c406c90e36..b2c4e4b54a4 100644 --- a/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb +++ b/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb @@ -5,6 +5,8 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do include Database::TableSchemaHelpers + subject(:dropper) { described_class.new } + let(:connection) { ActiveRecord::Base.connection } def expect_partition_present(name) @@ -23,10 +25,18 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do before do connection.execute(<<~SQL) + CREATE TABLE referenced_table ( + id bigserial primary key not null + ) + SQL + connection.execute(<<~SQL) + CREATE TABLE parent_table ( id bigserial not null, + referenced_id bigint not null, created_at timestamptz not null, - primary key (id, created_at) + primary key (id, created_at), + constraint fk_referenced foreign key (referenced_id) references referenced_table(id) ) PARTITION BY RANGE(created_at) SQL end @@ -59,7 +69,7 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do attached: false, drop_after: 1.day.from_now) - subject.perform + dropper.perform expect_partition_present('test_partition') end @@ -75,7 +85,7 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do end it 'drops the partition' do - subject.perform + dropper.perform expect(table_oid('test_partition')).to be_nil end @@ -86,16 +96,62 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do end it 'does not drop the partition' do - subject.perform + dropper.perform expect(table_oid('test_partition')).not_to be_nil end end + context 'removing foreign keys' do + it 'removes foreign keys from the table before dropping it' do + expect(dropper).to receive(:drop_detached_partition).and_wrap_original do |drop_method, partition_name| + expect(partition_name).to eq('test_partition') + expect(foreign_key_exists_by_name(partition_name, 'fk_referenced', schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)).to be_falsey + + drop_method.call(partition_name) + end + + expect(foreign_key_exists_by_name('test_partition', 'fk_referenced', schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)).to be_truthy + + dropper.perform + end + + it 'does not remove foreign keys from the parent table' do + expect { dropper.perform }.not_to change { foreign_key_exists_by_name('parent_table', 'fk_referenced') }.from(true) + end + + context 'when another process drops the foreign key' do + it 'skips dropping that foreign key' do + expect(dropper).to receive(:drop_foreign_key_if_present).and_wrap_original do |drop_meth, *args| + connection.execute('alter table gitlab_partitions_dynamic.test_partition drop constraint fk_referenced;') + drop_meth.call(*args) + end + + dropper.perform + + expect_partition_removed('test_partition') + end + end + + context 'when another process drops the partition' do + it 'skips dropping the foreign key' do + expect(dropper).to receive(:drop_foreign_key_if_present).and_wrap_original do |drop_meth, *args| + connection.execute('drop table gitlab_partitions_dynamic.test_partition') + Postgresql::DetachedPartition.where(table_name: 'test_partition').delete_all + end + + expect(Gitlab::AppLogger).not_to receive(:error) + dropper.perform + end + end + end + context 'when another process drops the table while the first waits for a lock' do it 'skips the table' do + # First call to .lock is for removing foreign keys + expect(Postgresql::DetachedPartition).to receive(:lock).once.ordered.and_call_original # Rspec's receive_method_chain does not support .and_wrap_original, so we need to nest here. - expect(Postgresql::DetachedPartition).to receive(:lock).and_wrap_original do |lock_meth| + expect(Postgresql::DetachedPartition).to receive(:lock).once.ordered.and_wrap_original do |lock_meth| locked = lock_meth.call expect(locked).to receive(:find_by).and_wrap_original do |find_meth, *find_args| # Another process drops the table then deletes this entry @@ -106,9 +162,9 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do locked end - expect(subject).not_to receive(:drop_one) + expect(dropper).not_to receive(:drop_one) - subject.perform + dropper.perform end end end @@ -123,19 +179,26 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do end it 'does not drop the partition, but does remove the DetachedPartition entry' do - subject.perform + dropper.perform aggregate_failures do expect(table_oid('test_partition')).not_to be_nil expect(Postgresql::DetachedPartition.find_by(table_name: 'test_partition')).to be_nil end end - it 'removes the detached_partition entry' do - detached_partition = Postgresql::DetachedPartition.find_by!(table_name: 'test_partition') + context 'when another process removes the entry before this process' do + it 'does nothing' do + expect(Postgresql::DetachedPartition).to receive(:lock).and_wrap_original do |lock_meth| + Postgresql::DetachedPartition.delete_all + lock_meth.call + end - subject.perform + expect(Gitlab::AppLogger).not_to receive(:error) - expect(Postgresql::DetachedPartition.exists?(id: detached_partition.id)).to be_falsey + dropper.perform + + expect(table_oid('test_partition')).not_to be_nil + end end end @@ -155,7 +218,7 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do end it 'drops both partitions' do - subject.perform + dropper.perform expect_partition_removed('partition_1') expect_partition_removed('partition_2') @@ -163,10 +226,10 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do context 'when the first drop returns an error' do it 'still drops the second partition' do - expect(subject).to receive(:drop_detached_partition).ordered.and_raise('injected error') - expect(subject).to receive(:drop_detached_partition).ordered.and_call_original + expect(dropper).to receive(:drop_detached_partition).ordered.and_raise('injected error') + expect(dropper).to receive(:drop_detached_partition).ordered.and_call_original - subject.perform + dropper.perform # We don't know which partition we tried to drop first, so the tests here have to work with either one expect(Postgresql::DetachedPartition.count).to eq(1) diff --git a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb index 27ada12b067..67d80d71e2a 100644 --- a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb +++ b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do let(:model) { double('model', table_name: table_name) } let(:partitioning_key) { double } - let(:table_name) { :partitioned_test } + let(:table_name) { :_test_partitioned_test } before do connection.execute(<<~SQL) @@ -18,11 +18,11 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do (id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at); - CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.partitioned_test_000000 + CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000 PARTITION OF #{table_name} FOR VALUES FROM (MINVALUE) TO ('2020-05-01'); - CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.partitioned_test_202005 + CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202005 PARTITION OF #{table_name} FOR VALUES FROM ('2020-05-01') TO ('2020-06-01'); SQL @@ -30,8 +30,8 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do it 'detects both partitions' do expect(subject).to eq([ - Gitlab::Database::Partitioning::TimePartition.new(table_name, nil, '2020-05-01', partition_name: 'partitioned_test_000000'), - Gitlab::Database::Partitioning::TimePartition.new(table_name, '2020-05-01', '2020-06-01', partition_name: 'partitioned_test_202005') + Gitlab::Database::Partitioning::TimePartition.new(table_name, nil, '2020-05-01', partition_name: '_test_partitioned_test_000000'), + Gitlab::Database::Partitioning::TimePartition.new(table_name, '2020-05-01', '2020-06-01', partition_name: '_test_partitioned_test_202005') ]) end end @@ -41,7 +41,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do let(:model) do Class.new(ActiveRecord::Base) do - self.table_name = 'partitioned_test' + self.table_name = '_test_partitioned_test' self.primary_key = :id end end @@ -59,11 +59,11 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do (id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at); - CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.partitioned_test_000000 + CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000 PARTITION OF #{model.table_name} FOR VALUES FROM (MINVALUE) TO ('2020-05-01'); - CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.partitioned_test_202006 + CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006 PARTITION OF #{model.table_name} FOR VALUES FROM ('2020-06-01') TO ('2020-07-01'); SQL @@ -166,7 +166,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do (id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at); - CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.partitioned_test_202006 + CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006 PARTITION OF #{model.table_name} FOR VALUES FROM ('2020-06-01') TO ('2020-07-01'); SQL @@ -181,13 +181,13 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do describe '#extra_partitions' do let(:model) do Class.new(ActiveRecord::Base) do - self.table_name = 'partitioned_test' + self.table_name = '_test_partitioned_test' self.primary_key = :id end end let(:partitioning_key) { :created_at } - let(:table_name) { :partitioned_test } + let(:table_name) { :_test_partitioned_test } around do |example| travel_to(Date.parse('2020-08-22')) { example.run } @@ -200,15 +200,15 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do (id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at); - CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.partitioned_test_000000 + CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_000000 PARTITION OF #{table_name} FOR VALUES FROM (MINVALUE) TO ('2020-05-01'); - CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.partitioned_test_202005 + CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202005 PARTITION OF #{table_name} FOR VALUES FROM ('2020-05-01') TO ('2020-06-01'); - CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.partitioned_test_202006 + CREATE TABLE #{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_partitioned_test_202006 PARTITION OF #{table_name} FOR VALUES FROM ('2020-06-01') TO ('2020-07-01') SQL @@ -235,7 +235,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do it 'prunes the unbounded partition ending 2020-05-01' do min_value_to_may = Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', - partition_name: 'partitioned_test_000000') + partition_name: '_test_partitioned_test_000000') expect(subject).to contain_exactly(min_value_to_may) end @@ -246,8 +246,8 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do it 'prunes the unbounded partition and the partition for May-June' do expect(subject).to contain_exactly( - Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: 'partitioned_test_000000'), - Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: 'partitioned_test_202005') + Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: '_test_partitioned_test_000000'), + Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: '_test_partitioned_test_202005') ) end @@ -256,16 +256,16 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do it 'prunes empty partitions' do expect(subject).to contain_exactly( - Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: 'partitioned_test_000000'), - Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: 'partitioned_test_202005') + Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: '_test_partitioned_test_000000'), + Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: '_test_partitioned_test_202005') ) end it 'does not prune non-empty partitions' do - connection.execute("INSERT INTO #{table_name} (created_at) VALUES (('2020-05-15'))") # inserting one record into partitioned_test_202005 + connection.execute("INSERT INTO #{table_name} (created_at) VALUES (('2020-05-15'))") # inserting one record into _test_partitioned_test_202005 expect(subject).to contain_exactly( - Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: 'partitioned_test_000000') + Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: '_test_partitioned_test_000000') ) end end diff --git a/spec/lib/gitlab/database/partitioning/multi_database_partition_dropper_spec.rb b/spec/lib/gitlab/database/partitioning/multi_database_partition_dropper_spec.rb deleted file mode 100644 index 56d6ebb7aff..00000000000 --- a/spec/lib/gitlab/database/partitioning/multi_database_partition_dropper_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Database::Partitioning::MultiDatabasePartitionDropper, '#drop_detached_partitions' do - subject(:drop_detached_partitions) { multi_db_dropper.drop_detached_partitions } - - let(:multi_db_dropper) { described_class.new } - - let(:connection_wrapper1) { double(scope: scope1) } - let(:connection_wrapper2) { double(scope: scope2) } - - let(:scope1) { double(connection: connection1) } - let(:scope2) { double(connection: connection2) } - - let(:connection1) { double('connection') } - let(:connection2) { double('connection') } - - let(:dropper_class) { Gitlab::Database::Partitioning::DetachedPartitionDropper } - let(:dropper1) { double('partition dropper') } - let(:dropper2) { double('partition dropper') } - - before do - allow(multi_db_dropper).to receive(:databases).and_return({ db1: connection_wrapper1, db2: connection_wrapper2 }) - end - - it 'drops detached partitions for each database' do - expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(connection1).and_yield.ordered - expect(dropper_class).to receive(:new).and_return(dropper1).ordered - expect(dropper1).to receive(:perform) - - expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(connection2).and_yield.ordered - expect(dropper_class).to receive(:new).and_return(dropper2).ordered - expect(dropper2).to receive(:perform) - - drop_detached_partitions - end -end diff --git a/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb b/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb deleted file mode 100644 index 3c94c1bf4ea..00000000000 --- a/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Database::Partitioning::MultiDatabasePartitionManager, '#sync_partitions' do - subject(:sync_partitions) { manager.sync_partitions } - - let(:manager) { described_class.new(models) } - let(:models) { [model1, model2] } - - let(:model1) { double('model1', connection: connection1, table_name: 'table1') } - let(:model2) { double('model2', connection: connection1, table_name: 'table2') } - - let(:connection1) { double('connection1') } - let(:connection2) { double('connection2') } - - let(:target_manager_class) { Gitlab::Database::Partitioning::PartitionManager } - let(:target_manager1) { double('partition manager') } - let(:target_manager2) { double('partition manager') } - - before do - allow(manager).to receive(:connection_name).and_return('name') - end - - it 'syncs model partitions, setting up the appropriate connection for each', :aggregate_failures do - expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model1.connection).and_yield.ordered - expect(target_manager_class).to receive(:new).with(model1).and_return(target_manager1).ordered - expect(target_manager1).to receive(:sync_partitions) - - expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model2.connection).and_yield.ordered - expect(target_manager_class).to receive(:new).with(model2).and_return(target_manager2).ordered - expect(target_manager2).to receive(:sync_partitions) - - sync_partitions - end -end diff --git a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb index 7c4cfcfb3a9..1c6f5c5c694 100644 --- a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb +++ b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb @@ -195,7 +195,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do end # Postgres 11 does not support foreign keys to partitioned tables - if Gitlab::Database.main.version.to_f >= 12 + if ApplicationRecord.database.version.to_f >= 12 context 'when the model is the target of a foreign key' do before do connection.execute(<<~SQL) diff --git a/spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb b/spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb index 7024cbd55ff..006ce8a7f48 100644 --- a/spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb +++ b/spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb @@ -4,9 +4,8 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Partitioning::PartitionMonitoring do describe '#report_metrics' do - subject { described_class.new(models).report_metrics } + subject { described_class.new.report_metrics_for_model(model) } - let(:models) { [model] } let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table) } let(:partitioning_strategy) { double(missing_partitions: missing_partitions, current_partitions: current_partitions, extra_partitions: extra_partitions) } let(:table) { "some_table" } diff --git a/spec/lib/gitlab/database/partitioning/replace_table_spec.rb b/spec/lib/gitlab/database/partitioning/replace_table_spec.rb index 8e27797208c..fdf514b519f 100644 --- a/spec/lib/gitlab/database/partitioning/replace_table_spec.rb +++ b/spec/lib/gitlab/database/partitioning/replace_table_spec.rb @@ -5,7 +5,9 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Partitioning::ReplaceTable, '#perform' do include Database::TableSchemaHelpers - subject(:replace_table) { described_class.new(original_table, replacement_table, archived_table, 'id').perform } + subject(:replace_table) do + described_class.new(connection, original_table, replacement_table, archived_table, 'id').perform + end let(:original_table) { '_test_original_table' } let(:replacement_table) { '_test_replacement_table' } diff --git a/spec/lib/gitlab/database/partitioning_spec.rb b/spec/lib/gitlab/database/partitioning_spec.rb index 486af9413e8..154cc2b7972 100644 --- a/spec/lib/gitlab/database/partitioning_spec.rb +++ b/spec/lib/gitlab/database/partitioning_spec.rb @@ -3,52 +3,175 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Partitioning do + include Database::PartitioningHelpers + include Database::TableSchemaHelpers + + let(:connection) { ApplicationRecord.connection } + + around do |example| + previously_registered_models = described_class.registered_models.dup + described_class.instance_variable_set('@registered_models', Set.new) + + previously_registered_tables = described_class.registered_tables.dup + described_class.instance_variable_set('@registered_tables', Set.new) + + example.run + + described_class.instance_variable_set('@registered_models', previously_registered_models) + described_class.instance_variable_set('@registered_tables', previously_registered_tables) + end + + describe '.register_models' do + context 'ensure that the registered models have partitioning strategy' do + it 'fails when partitioning_strategy is not specified for the model' do + model = Class.new(ApplicationRecord) + expect { described_class.register_models([model]) }.to raise_error /should have partitioning strategy defined/ + end + end + end + + describe '.sync_partitions_ignore_db_error' do + it 'calls sync_partitions' do + expect(described_class).to receive(:sync_partitions) + + described_class.sync_partitions_ignore_db_error + end + + [ActiveRecord::ActiveRecordError, PG::Error].each do |error| + context "when #{error} is raised" do + before do + expect(described_class).to receive(:sync_partitions) + .and_raise(error) + end + + it 'ignores it' do + described_class.sync_partitions_ignore_db_error + end + end + end + + context 'when DISABLE_POSTGRES_PARTITION_CREATION_ON_STARTUP is set' do + before do + stub_env('DISABLE_POSTGRES_PARTITION_CREATION_ON_STARTUP', '1') + end + + it 'does not call sync_partitions' do + expect(described_class).to receive(:sync_partitions).never + + described_class.sync_partitions_ignore_db_error + end + end + end + describe '.sync_partitions' do - let(:partition_manager_class) { described_class::MultiDatabasePartitionManager } - let(:partition_manager) { double('partition manager') } + let(:table_names) { %w[partitioning_test1 partitioning_test2] } + let(:models) do + table_names.map do |table_name| + Class.new(ApplicationRecord) do + include PartitionedTable + + self.table_name = table_name + partitioned_by :created_at, strategy: :monthly + end + end + end + + before do + table_names.each do |table_name| + connection.execute(<<~SQL) + CREATE TABLE #{table_name} ( + id serial not null, + created_at timestamptz not null, + PRIMARY KEY (id, created_at)) + PARTITION BY RANGE (created_at); + SQL + end + end + + it 'manages partitions for each given model' do + expect { described_class.sync_partitions(models)} + .to change { find_partitions(table_names.first).size }.from(0) + .and change { find_partitions(table_names.last).size }.from(0) + end context 'when no partitioned models are given' do - it 'calls the partition manager with the registered models' do - expect(partition_manager_class).to receive(:new) - .with(described_class.registered_models) - .and_return(partition_manager) + it 'manages partitions for each registered model' do + described_class.register_models([models.first]) + described_class.register_tables([ + { + table_name: table_names.last, + partitioned_column: :created_at, strategy: :monthly + } + ]) - expect(partition_manager).to receive(:sync_partitions) + expect { described_class.sync_partitions } + .to change { find_partitions(table_names.first).size }.from(0) + .and change { find_partitions(table_names.last).size }.from(0) + end + end + end + + describe '.report_metrics' do + let(:model1) { double('model') } + let(:model2) { double('model') } + + let(:partition_monitoring_class) { described_class::PartitionMonitoring } + + context 'when no partitioned models are given' do + it 'reports metrics for each registered model' do + expect_next_instance_of(partition_monitoring_class) do |partition_monitor| + expect(partition_monitor).to receive(:report_metrics_for_model).with(model1) + expect(partition_monitor).to receive(:report_metrics_for_model).with(model2) + end + + expect(Gitlab::Database::EachDatabase).to receive(:each_model_connection) + .with(described_class.__send__(:registered_models)) + .and_yield(model1) + .and_yield(model2) - described_class.sync_partitions + described_class.report_metrics end end context 'when partitioned models are given' do - it 'calls the partition manager with the given models' do - models = ['my special model'] + it 'reports metrics for each given model' do + expect_next_instance_of(partition_monitoring_class) do |partition_monitor| + expect(partition_monitor).to receive(:report_metrics_for_model).with(model1) + expect(partition_monitor).to receive(:report_metrics_for_model).with(model2) + end - expect(partition_manager_class).to receive(:new) - .with(models) - .and_return(partition_manager) + expect(Gitlab::Database::EachDatabase).to receive(:each_model_connection) + .with([model1, model2]) + .and_yield(model1) + .and_yield(model2) - expect(partition_manager).to receive(:sync_partitions) - - described_class.sync_partitions(models) + described_class.report_metrics([model1, model2]) end end end describe '.drop_detached_partitions' do - let(:partition_dropper_class) { described_class::MultiDatabasePartitionDropper } + let(:table_names) { %w[detached_test_partition1 detached_test_partition2] } + + before do + table_names.each do |table_name| + connection.create_table("#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.#{table_name}") - it 'delegates to the partition dropper' do - expect_next_instance_of(partition_dropper_class) do |partition_dropper| - expect(partition_dropper).to receive(:drop_detached_partitions) + Postgresql::DetachedPartition.create!(table_name: table_name, drop_after: 1.year.ago) end + end - described_class.drop_detached_partitions + it 'drops detached partitions for each database' do + expect(Gitlab::Database::EachDatabase).to receive(:each_database_connection).and_yield + + expect { described_class.drop_detached_partitions } + .to change { Postgresql::DetachedPartition.count }.from(2).to(0) + .and change { table_exists?(table_names.first) }.from(true).to(false) + .and change { table_exists?(table_names.last) }.from(true).to(false) end - end - context 'ensure that the registered models have partitioning strategy' do - it 'fails when partitioning_strategy is not specified for the model' do - expect(described_class.registered_models).to all(respond_to(:partitioning_strategy)) + def table_exists?(table_name) + table_oid(table_name).present? end end end diff --git a/spec/lib/gitlab/database/postgres_foreign_key_spec.rb b/spec/lib/gitlab/database/postgres_foreign_key_spec.rb index ec39e5bfee7..b0e08ca1e67 100644 --- a/spec/lib/gitlab/database/postgres_foreign_key_spec.rb +++ b/spec/lib/gitlab/database/postgres_foreign_key_spec.rb @@ -38,4 +38,16 @@ RSpec.describe Gitlab::Database::PostgresForeignKey, type: :model do expect(described_class.by_referenced_table_identifier('public.referenced_table')).to contain_exactly(expected) end end + + describe '#by_constrained_table_identifier' do + it 'throws an error when the identifier name is not fully qualified' do + expect { described_class.by_constrained_table_identifier('constrained_table') }.to raise_error(ArgumentError, /not fully qualified/) + end + + it 'finds the foreign keys for the constrained table' do + expected = described_class.where(name: %w[fk_constrained_to_referenced fk_constrained_to_other_referenced]).to_a + + expect(described_class.by_constrained_table_identifier('public.constrained_table')).to match_array(expected) + end + end end diff --git a/spec/lib/gitlab/database/postgres_hll/batch_distinct_counter_spec.rb b/spec/lib/gitlab/database/postgres_hll/batch_distinct_counter_spec.rb index 2c550f14a08..c9bbc32e059 100644 --- a/spec/lib/gitlab/database/postgres_hll/batch_distinct_counter_spec.rb +++ b/spec/lib/gitlab/database/postgres_hll/batch_distinct_counter_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Gitlab::Database::PostgresHll::BatchDistinctCounter do end before do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(in_transaction) + allow(model.connection).to receive(:transaction_open?).and_return(in_transaction) end context 'unit test for different counting parameters' do diff --git a/spec/lib/gitlab/database/postgres_index_bloat_estimate_spec.rb b/spec/lib/gitlab/database/postgres_index_bloat_estimate_spec.rb index da4422bd442..13ac9190ab7 100644 --- a/spec/lib/gitlab/database/postgres_index_bloat_estimate_spec.rb +++ b/spec/lib/gitlab/database/postgres_index_bloat_estimate_spec.rb @@ -13,6 +13,8 @@ RSpec.describe Gitlab::Database::PostgresIndexBloatEstimate do let(:identifier) { 'public.schema_migrations_pkey' } + it { is_expected.to be_a Gitlab::Database::SharedModel } + describe '#bloat_size' do it 'returns the bloat size in bytes' do # We cannot reach much more about the bloat size estimate here diff --git a/spec/lib/gitlab/database/postgres_index_spec.rb b/spec/lib/gitlab/database/postgres_index_spec.rb index 9088719d5a4..db66736676b 100644 --- a/spec/lib/gitlab/database/postgres_index_spec.rb +++ b/spec/lib/gitlab/database/postgres_index_spec.rb @@ -22,6 +22,8 @@ RSpec.describe Gitlab::Database::PostgresIndex do it_behaves_like 'a postgres model' + it { is_expected.to be_a Gitlab::Database::SharedModel } + describe '.reindexing_support' do it 'only non partitioned indexes' do expect(described_class.reindexing_support).to all(have_attributes(partitioned: false)) diff --git a/spec/lib/gitlab/database/query_analyzer_spec.rb b/spec/lib/gitlab/database/query_analyzer_spec.rb new file mode 100644 index 00000000000..82a1c7143d5 --- /dev/null +++ b/spec/lib/gitlab/database/query_analyzer_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::QueryAnalyzer, query_analyzers: false do + let(:analyzer) { double(:query_analyzer) } + let(:disabled_analyzer) { double(:disabled_query_analyzer) } + + before do + allow(described_class.instance).to receive(:all_analyzers).and_return([analyzer, disabled_analyzer]) + allow(analyzer).to receive(:enabled?).and_return(true) + allow(analyzer).to receive(:suppressed?).and_return(false) + allow(analyzer).to receive(:begin!) + allow(analyzer).to receive(:end!) + allow(disabled_analyzer).to receive(:enabled?).and_return(false) + end + + context 'the hook is enabled by default in specs' do + it 'does process queries and gets normalized SQL' do + expect(analyzer).to receive(:enabled?).and_return(true) + expect(analyzer).to receive(:analyze) do |parsed| + expect(parsed.sql).to include("SELECT $1 FROM projects") + expect(parsed.pg.tables).to eq(%w[projects]) + end + + described_class.instance.within do + Project.connection.execute("SELECT 1 FROM projects") + end + end + + it 'does prevent recursive execution' do + expect(analyzer).to receive(:enabled?).and_return(true) + expect(analyzer).to receive(:analyze) do + Project.connection.execute("SELECT 1 FROM projects") + end + + described_class.instance.within do + Project.connection.execute("SELECT 1 FROM projects") + end + end + end + + describe '#within' do + context 'when it is already initialized' do + around do |example| + described_class.instance.within do + example.run + end + end + + it 'does not evaluate enabled? again do yield block' do + expect(analyzer).not_to receive(:enabled?) + + expect { |b| described_class.instance.within(&b) }.to yield_control + end + end + + context 'when initializer is enabled' do + before do + expect(analyzer).to receive(:enabled?).and_return(true) + end + + it 'calls begin! and end!' do + expect(analyzer).to receive(:begin!) + expect(analyzer).to receive(:end!) + + expect { |b| described_class.instance.within(&b) }.to yield_control + end + + it 'when begin! raises the end! is not called' do + expect(analyzer).to receive(:begin!).and_raise('exception') + expect(analyzer).not_to receive(:end!) + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + + expect { |b| described_class.instance.within(&b) }.to yield_control + end + end + end + + describe '#process_sql' do + it 'does not analyze query if not enabled' do + expect(analyzer).to receive(:enabled?).and_return(false) + expect(analyzer).not_to receive(:analyze) + + process_sql("SELECT 1 FROM projects") + end + + it 'does analyze query if enabled' do + expect(analyzer).to receive(:enabled?).and_return(true) + expect(analyzer).to receive(:analyze) do |parsed| + expect(parsed.sql).to eq("SELECT $1 FROM projects") + expect(parsed.pg.tables).to eq(%w[projects]) + end + + process_sql("SELECT 1 FROM projects") + end + + it 'does track exception if query cannot be parsed' do + expect(analyzer).to receive(:enabled?).and_return(true) + expect(analyzer).not_to receive(:analyze) + expect(Gitlab::ErrorTracking).to receive(:track_exception) + + expect { process_sql("invalid query") }.not_to raise_error + end + + it 'does track exception if analyzer raises exception on enabled?' do + expect(analyzer).to receive(:enabled?).and_raise('exception') + expect(analyzer).not_to receive(:analyze) + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + + expect { process_sql("SELECT 1 FROM projects") }.not_to raise_error + end + + it 'does track exception if analyzer raises exception on analyze' do + expect(analyzer).to receive(:enabled?).and_return(true) + expect(analyzer).to receive(:analyze).and_raise('exception') + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + + expect { process_sql("SELECT 1 FROM projects") }.not_to raise_error + end + + it 'does call analyze only on enabled initializers' do + expect(analyzer).to receive(:analyze) + expect(disabled_analyzer).not_to receive(:analyze) + + expect { process_sql("SELECT 1 FROM projects") }.not_to raise_error + end + + it 'does not call analyze on suppressed analyzers' do + expect(analyzer).to receive(:suppressed?).and_return(true) + expect(analyzer).not_to receive(:analyze) + + expect { process_sql("SELECT 1 FROM projects") }.not_to raise_error + end + + def process_sql(sql) + described_class.instance.within do + ApplicationRecord.load_balancer.read_write do |connection| + described_class.instance.process_sql(sql, connection) + end + end + end + end +end 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 new file mode 100644 index 00000000000..ab5f05e3ec4 --- /dev/null +++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_analyzers: false do + let(:analyzer) { described_class } + + before do + allow(Gitlab::Database::QueryAnalyzer.instance).to receive(:all_analyzers).and_return([analyzer]) + end + + it 'does not increment metrics if feature flag is disabled' do + stub_feature_flags(query_analyzer_gitlab_schema_metrics: false) + + expect(analyzer).not_to receive(:analyze) + + process_sql(ActiveRecord::Base, "SELECT 1 FROM projects") + end + + context 'properly observes all queries', :mocked_ci_connection do + using RSpec::Parameterized::TableSyntax + + where do + { + "for simple query observes schema correctly" => { + model: ApplicationRecord, + sql: "SELECT 1 FROM projects", + expectations: { + gitlab_schemas: "gitlab_main", + db_config_name: "main" + } + }, + "for query accessing gitlab_ci and gitlab_main" => { + model: ApplicationRecord, + sql: "SELECT 1 FROM projects LEFT JOIN ci_builds ON ci_builds.project_id=projects.id", + expectations: { + gitlab_schemas: "gitlab_ci,gitlab_main", + db_config_name: "main" + } + }, + "for query accessing gitlab_ci and gitlab_main the gitlab_schemas is always ordered" => { + model: ApplicationRecord, + sql: "SELECT 1 FROM ci_builds LEFT JOIN projects ON ci_builds.project_id=projects.id", + expectations: { + gitlab_schemas: "gitlab_ci,gitlab_main", + db_config_name: "main" + } + }, + "for query accessing CI database" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci" + } + } + } + end + + with_them do + around do |example| + Gitlab::Database::QueryAnalyzer.instance.within { example.run } + end + + it do + expect(described_class.schemas_metrics).to receive(:increment) + .with(expectations).and_call_original + + process_sql(model, sql) + end + end + end + + def process_sql(model, sql) + Gitlab::Database::QueryAnalyzer.instance.within do + # Skip load balancer and retrieve connection assigned to model + Gitlab::Database::QueryAnalyzer.instance.process_sql(sql, model.retrieve_connection) + end + end +end diff --git a/spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb b/spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb new file mode 100644 index 00000000000..eb8ccb0bd89 --- /dev/null +++ b/spec/lib/gitlab/database/query_analyzers/prevent_cross_database_modification_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification, query_analyzers: false do + let_it_be(:pipeline, refind: true) { create(:ci_pipeline) } + let_it_be(:project, refind: true) { create(:project) } + + before do + allow(Gitlab::Database::QueryAnalyzer.instance).to receive(:all_analyzers).and_return([described_class]) + end + + around do |example| + Gitlab::Database::QueryAnalyzer.instance.within { example.run } + end + + shared_examples 'successful examples' do + context 'outside transaction' do + it { expect { run_queries }.not_to raise_error } + end + + context 'within transaction' do + it do + Project.transaction do + expect { run_queries }.not_to raise_error + end + end + end + + context 'within nested transaction' do + it do + Project.transaction(requires_new: true) do + Project.transaction(requires_new: true) do + expect { run_queries }.not_to raise_error + end + end + end + end + end + + context 'when CI and other tables are read in a transaction' do + def run_queries + pipeline.reload + project.reload + end + + include_examples 'successful examples' + end + + context 'when only CI data is modified' do + def run_queries + pipeline.touch + project.reload + end + + include_examples 'successful examples' + end + + context 'when other data is modified' do + def run_queries + pipeline.reload + project.touch + end + + include_examples 'successful examples' + end + + context 'when both CI and other data is modified' do + def run_queries + project.touch + pipeline.touch + end + + context 'outside transaction' do + it { expect { run_queries }.not_to raise_error } + end + + context 'when data modification happens in a transaction' do + it 'raises error' do + Project.transaction do + expect { run_queries }.to raise_error /Cross-database data modification/ + end + end + + context 'when data modification happens in nested transactions' do + it 'raises error' do + Project.transaction(requires_new: true) do + project.touch + Project.transaction(requires_new: true) do + expect { pipeline.touch }.to raise_error /Cross-database data modification/ + end + end + end + end + end + + context 'when executing a SELECT FOR UPDATE query' do + def run_queries + project.touch + pipeline.lock! + end + + context 'outside transaction' do + it { expect { run_queries }.not_to raise_error } + end + + context 'when data modification happens in a transaction' do + it 'raises error' do + Project.transaction do + expect { run_queries }.to raise_error /Cross-database data modification/ + end + end + + context 'when the modification is inside a factory save! call' do + let(:runner) { create(:ci_runner, :project, projects: [build(:project)]) } + + it 'does not raise an error' do + runner + end + end + end + end + + context 'when CI association is modified through project' do + def run_queries + project.variables.build(key: 'a', value: 'v') + project.save! + end + + include_examples 'successful examples' + end + + describe '.allow_cross_database_modification_within_transaction' do + it 'skips raising error' do + expect do + described_class.allow_cross_database_modification_within_transaction(url: 'gitlab-issue') do + Project.transaction do + pipeline.touch + project.touch + end + end + end.not_to raise_error + end + + it 'skips raising error on factory creation' do + expect do + described_class.allow_cross_database_modification_within_transaction(url: 'gitlab-issue') do + ApplicationRecord.transaction do + create(:ci_pipeline) + end + end + end.not_to raise_error + end + end + end + + context 'when some table with a defined schema and another table with undefined gitlab_schema is modified' do + it 'raises an error including including message about undefined schema' do + expect do + Project.transaction do + project.touch + project.connection.execute('UPDATE foo_bars_undefined_table SET a=1 WHERE id = -1') + end + end.to raise_error /Cross-database data modification.*The gitlab_schema was undefined/ + end + end +end diff --git a/spec/lib/gitlab/database/reflection_spec.rb b/spec/lib/gitlab/database/reflection_spec.rb new file mode 100644 index 00000000000..7c3d797817d --- /dev/null +++ b/spec/lib/gitlab/database/reflection_spec.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::Reflection do + let(:database) { described_class.new(ApplicationRecord) } + + describe '#username' do + context 'when a username is set' do + it 'returns the username' do + allow(database).to receive(:config).and_return(username: 'bob') + + expect(database.username).to eq('bob') + end + end + + context 'when a username is not set' do + it 'returns the value of the USER environment variable' do + allow(database).to receive(:config).and_return(username: nil) + allow(ENV).to receive(:[]).with('USER').and_return('bob') + + expect(database.username).to eq('bob') + end + end + end + + describe '#database_name' do + it 'returns the name of the database' do + allow(database).to receive(:config).and_return(database: 'test') + + expect(database.database_name).to eq('test') + end + end + + describe '#adapter_name' do + it 'returns the database adapter name' do + allow(database).to receive(:config).and_return(adapter: 'test') + + expect(database.adapter_name).to eq('test') + end + end + + describe '#human_adapter_name' do + context 'when the adapter is PostgreSQL' do + it 'returns PostgreSQL' do + allow(database).to receive(:config).and_return(adapter: 'postgresql') + + expect(database.human_adapter_name).to eq('PostgreSQL') + end + end + + context 'when the adapter is not PostgreSQL' do + it 'returns Unknown' do + allow(database).to receive(:config).and_return(adapter: 'kittens') + + expect(database.human_adapter_name).to eq('Unknown') + end + end + end + + describe '#postgresql?' do + context 'when using PostgreSQL' do + it 'returns true' do + allow(database).to receive(:adapter_name).and_return('PostgreSQL') + + expect(database.postgresql?).to eq(true) + end + end + + context 'when not using PostgreSQL' do + it 'returns false' do + allow(database).to receive(:adapter_name).and_return('MySQL') + + expect(database.postgresql?).to eq(false) + end + end + end + + describe '#db_read_only?' do + it 'detects a read-only database' do + allow(database.model.connection) + .to receive(:execute) + .with('SELECT pg_is_in_recovery()') + .and_return([{ "pg_is_in_recovery" => "t" }]) + + expect(database.db_read_only?).to be_truthy + end + + it 'detects a read-only database' do + allow(database.model.connection) + .to receive(:execute) + .with('SELECT pg_is_in_recovery()') + .and_return([{ "pg_is_in_recovery" => true }]) + + expect(database.db_read_only?).to be_truthy + end + + it 'detects a read-write database' do + allow(database.model.connection) + .to receive(:execute) + .with('SELECT pg_is_in_recovery()') + .and_return([{ "pg_is_in_recovery" => "f" }]) + + expect(database.db_read_only?).to be_falsey + end + + it 'detects a read-write database' do + allow(database.model.connection) + .to receive(:execute) + .with('SELECT pg_is_in_recovery()') + .and_return([{ "pg_is_in_recovery" => false }]) + + expect(database.db_read_only?).to be_falsey + end + end + + describe '#db_read_write?' do + it 'detects a read-only database' do + allow(database.model.connection) + .to receive(:execute) + .with('SELECT pg_is_in_recovery()') + .and_return([{ "pg_is_in_recovery" => "t" }]) + + expect(database.db_read_write?).to eq(false) + end + + it 'detects a read-only database' do + allow(database.model.connection) + .to receive(:execute) + .with('SELECT pg_is_in_recovery()') + .and_return([{ "pg_is_in_recovery" => true }]) + + expect(database.db_read_write?).to eq(false) + end + + it 'detects a read-write database' do + allow(database.model.connection) + .to receive(:execute) + .with('SELECT pg_is_in_recovery()') + .and_return([{ "pg_is_in_recovery" => "f" }]) + + expect(database.db_read_write?).to eq(true) + end + + it 'detects a read-write database' do + allow(database.model.connection) + .to receive(:execute) + .with('SELECT pg_is_in_recovery()') + .and_return([{ "pg_is_in_recovery" => false }]) + + expect(database.db_read_write?).to eq(true) + end + end + + describe '#version' do + around do |example| + database.instance_variable_set(:@version, nil) + example.run + database.instance_variable_set(:@version, nil) + end + + context "on postgresql" do + it "extracts the version number" do + allow(database) + .to receive(:database_version) + .and_return("PostgreSQL 9.4.4 on x86_64-apple-darwin14.3.0") + + expect(database.version).to eq '9.4.4' + end + end + + it 'memoizes the result' do + count = ActiveRecord::QueryRecorder + .new { 2.times { database.version } } + .count + + expect(count).to eq(1) + end + end + + describe '#postgresql_minimum_supported_version?' do + it 'returns false when using PostgreSQL 10' do + allow(database).to receive(:version).and_return('10') + + expect(database.postgresql_minimum_supported_version?).to eq(false) + end + + it 'returns false when using PostgreSQL 11' do + allow(database).to receive(:version).and_return('11') + + expect(database.postgresql_minimum_supported_version?).to eq(false) + end + + it 'returns true when using PostgreSQL 12' do + allow(database).to receive(:version).and_return('12') + + expect(database.postgresql_minimum_supported_version?).to eq(true) + end + end + + describe '#cached_column_exists?' do + it 'only retrieves the data from the schema cache' do + database = described_class.new(Project) + queries = ActiveRecord::QueryRecorder.new do + 2.times do + expect(database.cached_column_exists?(:id)).to be_truthy + expect(database.cached_column_exists?(:bogus_column)).to be_falsey + end + end + + expect(queries.count).to eq(0) + end + end + + describe '#cached_table_exists?' do + it 'only retrieves the data from the schema cache' do + dummy = Class.new(ActiveRecord::Base) do + self.table_name = 'bogus_table_name' + end + + queries = ActiveRecord::QueryRecorder.new do + 2.times do + expect(described_class.new(Project).cached_table_exists?).to be_truthy + expect(described_class.new(dummy).cached_table_exists?).to be_falsey + end + end + + expect(queries.count).to eq(0) + end + + it 'returns false when database does not exist' do + database = described_class.new(Project) + + expect(database.model).to receive(:connection) do + raise ActiveRecord::NoDatabaseError, 'broken' + end + + expect(database.cached_table_exists?).to be(false) + end + end + + describe '#exists?' do + it 'returns true if the database exists' do + expect(database.exists?).to be(true) + end + + it "returns false if the database doesn't exist" do + expect(database.model.connection.schema_cache) + .to receive(:database_version) + .and_raise(ActiveRecord::NoDatabaseError) + + expect(database.exists?).to be(false) + end + end + + describe '#system_id' do + it 'returns the PostgreSQL system identifier' do + expect(database.system_id).to be_an_instance_of(Integer) + end + end + + describe '#config' do + it 'returns a HashWithIndifferentAccess' do + expect(database.config) + .to be_an_instance_of(HashWithIndifferentAccess) + end + + it 'returns a default pool size' do + expect(database.config) + .to include(pool: Gitlab::Database.default_pool_size) + end + + it 'does not cache its results' do + a = database.config + b = database.config + + expect(a).not_to equal(b) + end + end +end diff --git a/spec/lib/gitlab/database/reindexing/index_selection_spec.rb b/spec/lib/gitlab/database/reindexing/index_selection_spec.rb index ee3f2b1b415..2ae9037959d 100644 --- a/spec/lib/gitlab/database/reindexing/index_selection_spec.rb +++ b/spec/lib/gitlab/database/reindexing/index_selection_spec.rb @@ -46,14 +46,14 @@ RSpec.describe Gitlab::Database::Reindexing::IndexSelection do expect(subject).not_to include(excluded.index) end - it 'excludes indexes larger than 100 GB ondisk size' do - excluded = create( + it 'includes indexes larger than 100 GB ondisk size' do + included = create( :postgres_index_bloat_estimate, index: create(:postgres_index, ondisk_size_bytes: 101.gigabytes), bloat_size_bytes: 25.gigabyte ) - expect(subject).not_to include(excluded.index) + expect(subject).to include(included.index) end context 'with time frozen' do diff --git a/spec/lib/gitlab/database/reindexing/reindex_action_spec.rb b/spec/lib/gitlab/database/reindexing/reindex_action_spec.rb index a8f196d8f0e..1b409924acc 100644 --- a/spec/lib/gitlab/database/reindexing/reindex_action_spec.rb +++ b/spec/lib/gitlab/database/reindexing/reindex_action_spec.rb @@ -11,6 +11,8 @@ RSpec.describe Gitlab::Database::Reindexing::ReindexAction do swapout_view_for_table(:postgres_indexes) end + it { is_expected.to be_a Gitlab::Database::SharedModel } + describe '.create_for' do subject { described_class.create_for(index) } diff --git a/spec/lib/gitlab/database/reindexing/reindex_concurrently_spec.rb b/spec/lib/gitlab/database/reindexing/reindex_concurrently_spec.rb index 6f87475fc94..db267ff4f14 100644 --- a/spec/lib/gitlab/database/reindexing/reindex_concurrently_spec.rb +++ b/spec/lib/gitlab/database/reindexing/reindex_concurrently_spec.rb @@ -62,7 +62,7 @@ RSpec.describe Gitlab::Database::Reindexing::ReindexConcurrently, '#perform' do it 'recreates the index using REINDEX with a long statement timeout' do expect_to_execute_in_order( - "SET statement_timeout TO '32400s'", + "SET statement_timeout TO '86400s'", "REINDEX INDEX CONCURRENTLY \"public\".\"#{index.name}\"", "RESET statement_timeout" ) @@ -84,7 +84,7 @@ RSpec.describe Gitlab::Database::Reindexing::ReindexConcurrently, '#perform' do it 'drops the dangling indexes while controlling lock_timeout' do expect_to_execute_in_order( # Regular index rebuild - "SET statement_timeout TO '32400s'", + "SET statement_timeout TO '86400s'", "REINDEX INDEX CONCURRENTLY \"public\".\"#{index_name}\"", "RESET statement_timeout", # Drop _ccnew index diff --git a/spec/lib/gitlab/database/reindexing_spec.rb b/spec/lib/gitlab/database/reindexing_spec.rb index 550f9db2b5b..13aff343432 100644 --- a/spec/lib/gitlab/database/reindexing_spec.rb +++ b/spec/lib/gitlab/database/reindexing_spec.rb @@ -4,10 +4,63 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Reindexing do include ExclusiveLeaseHelpers + include Database::DatabaseHelpers - describe '.perform' do - subject { described_class.perform(candidate_indexes) } + describe '.automatic_reindexing' do + subject { described_class.automatic_reindexing(maximum_records: limit) } + let(:limit) { 5 } + + before_all do + swapout_view_for_table(:postgres_indexes) + end + + before do + allow(Gitlab::Database::Reindexing).to receive(:cleanup_leftovers!) + allow(Gitlab::Database::Reindexing).to receive(:perform_from_queue).and_return(0) + allow(Gitlab::Database::Reindexing).to receive(:perform_with_heuristic).and_return(0) + end + + it 'cleans up leftovers, before consuming the queue' do + expect(Gitlab::Database::Reindexing).to receive(:cleanup_leftovers!).ordered + expect(Gitlab::Database::Reindexing).to receive(:perform_from_queue).ordered + + subject + end + + context 'with records in the queue' do + before do + create(:reindexing_queued_action) + end + + context 'with enough records in the queue to reach limit' do + let(:limit) { 1 } + + it 'does not perform reindexing with heuristic' do + expect(Gitlab::Database::Reindexing).to receive(:perform_from_queue).and_return(limit) + expect(Gitlab::Database::Reindexing).not_to receive(:perform_with_heuristic) + + subject + end + end + + context 'without enough records in the queue to reach limit' do + let(:limit) { 2 } + + it 'continues if the queue did not have enough records' do + expect(Gitlab::Database::Reindexing).to receive(:perform_from_queue).ordered.and_return(1) + expect(Gitlab::Database::Reindexing).to receive(:perform_with_heuristic).with(maximum_records: 1).ordered + + subject + end + end + end + end + + describe '.perform_with_heuristic' do + subject { described_class.perform_with_heuristic(candidate_indexes, maximum_records: limit) } + + let(:limit) { 2 } let(:coordinator) { instance_double(Gitlab::Database::Reindexing::Coordinator) } let(:index_selection) { instance_double(Gitlab::Database::Reindexing::IndexSelection) } let(:candidate_indexes) { double } @@ -15,7 +68,7 @@ RSpec.describe Gitlab::Database::Reindexing do it 'delegates to Coordinator' do expect(Gitlab::Database::Reindexing::IndexSelection).to receive(:new).with(candidate_indexes).and_return(index_selection) - expect(index_selection).to receive(:take).with(2).and_return(indexes) + expect(index_selection).to receive(:take).with(limit).and_return(indexes) indexes.each do |index| expect(Gitlab::Database::Reindexing::Coordinator).to receive(:new).with(index).and_return(coordinator) @@ -26,6 +79,59 @@ RSpec.describe Gitlab::Database::Reindexing do end end + describe '.perform_from_queue' do + subject { described_class.perform_from_queue(maximum_records: limit) } + + before_all do + swapout_view_for_table(:postgres_indexes) + end + + let(:limit) { 2 } + let(:queued_actions) { create_list(:reindexing_queued_action, 3) } + let(:coordinator) { instance_double(Gitlab::Database::Reindexing::Coordinator) } + + before do + queued_actions.take(limit).each do |action| + allow(Gitlab::Database::Reindexing::Coordinator).to receive(:new).with(action.index).and_return(coordinator) + allow(coordinator).to receive(:perform) + end + end + + it 'consumes the queue in order of created_at and applies the limit' do + queued_actions.take(limit).each do |action| + expect(Gitlab::Database::Reindexing::Coordinator).to receive(:new).ordered.with(action.index).and_return(coordinator) + expect(coordinator).to receive(:perform) + end + + subject + end + + it 'updates queued action and sets state to done' do + subject + + queue = queued_actions + + queue.shift(limit).each do |action| + expect(action.reload.state).to eq('done') + end + + queue.each do |action| + expect(action.reload.state).to eq('queued') + end + end + + it 'updates queued action upon error and sets state to failed' do + expect(Gitlab::Database::Reindexing::Coordinator).to receive(:new).ordered.with(queued_actions.first.index).and_return(coordinator) + expect(coordinator).to receive(:perform).and_raise('something went wrong') + + subject + + states = queued_actions.map(&:reload).map(&:state) + + expect(states).to eq(%w(failed done queued)) + end + end + describe '.cleanup_leftovers!' do subject { described_class.cleanup_leftovers! } 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 8c0c4155ccc..7caee414719 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 @@ -11,12 +11,12 @@ RSpec.describe Gitlab::Database::SchemaCacheWithRenamedTable do let(:new_model) do Class.new(ActiveRecord::Base) do - self.table_name = 'projects_new' + self.table_name = '_test_projects_new' end end before do - stub_const('Gitlab::Database::TABLES_TO_BE_RENAMED', { 'projects' => 'projects_new' }) + stub_const('Gitlab::Database::TABLES_TO_BE_RENAMED', { 'projects' => '_test_projects_new' }) end context 'when table is not renamed yet' do @@ -32,8 +32,8 @@ RSpec.describe Gitlab::Database::SchemaCacheWithRenamedTable do context 'when table is renamed' do before do - ActiveRecord::Base.connection.execute("ALTER TABLE projects RENAME TO projects_new") - ActiveRecord::Base.connection.execute("CREATE VIEW projects AS SELECT * FROM projects_new") + ActiveRecord::Base.connection.execute("ALTER TABLE projects RENAME TO _test_projects_new") + ActiveRecord::Base.connection.execute("CREATE VIEW projects AS SELECT * FROM _test_projects_new") old_model.reset_column_information ActiveRecord::Base.connection.schema_cache.clear! @@ -54,14 +54,14 @@ RSpec.describe Gitlab::Database::SchemaCacheWithRenamedTable do it 'has the same indexes' do indexes_for_old_table = ActiveRecord::Base.connection.schema_cache.indexes('projects') - indexes_for_new_table = ActiveRecord::Base.connection.schema_cache.indexes('projects_new') + indexes_for_new_table = ActiveRecord::Base.connection.schema_cache.indexes('_test_projects_new') expect(indexes_for_old_table).to eq(indexes_for_new_table) end it 'has the same column_hash' do columns_hash_for_old_table = ActiveRecord::Base.connection.schema_cache.columns_hash('projects') - columns_hash_for_new_table = ActiveRecord::Base.connection.schema_cache.columns_hash('projects_new') + columns_hash_for_new_table = ActiveRecord::Base.connection.schema_cache.columns_hash('_test_projects_new') expect(columns_hash_for_old_table).to eq(columns_hash_for_new_table) end diff --git a/spec/lib/gitlab/database/schema_migrations/context_spec.rb b/spec/lib/gitlab/database/schema_migrations/context_spec.rb index 0323fa22b78..07c97ea0ec3 100644 --- a/spec/lib/gitlab/database/schema_migrations/context_spec.rb +++ b/spec/lib/gitlab/database/schema_migrations/context_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do end context 'CI database' do - let(:connection_class) { Ci::CiDatabaseRecord } + let(:connection_class) { Ci::ApplicationRecord } it 'returns a directory path that is database specific' do skip_if_multiple_databases_not_setup diff --git a/spec/lib/gitlab/database/shared_model_spec.rb b/spec/lib/gitlab/database/shared_model_spec.rb index 5d616aeb05f..94f2b5a3434 100644 --- a/spec/lib/gitlab/database/shared_model_spec.rb +++ b/spec/lib/gitlab/database/shared_model_spec.rb @@ -27,6 +27,38 @@ RSpec.describe Gitlab::Database::SharedModel do end end + context 'when multiple connection overrides are nested', :aggregate_failures do + let(:second_connection) { double('connection') } + + it 'allows the nesting with the same connection object' do + expect_original_connection_around do + described_class.using_connection(new_connection) do + expect(described_class.connection).to be(new_connection) + + described_class.using_connection(new_connection) do + expect(described_class.connection).to be(new_connection) + end + + expect(described_class.connection).to be(new_connection) + end + end + end + + it 'raises an error if the connection is changed' do + expect_original_connection_around do + described_class.using_connection(new_connection) do + expect(described_class.connection).to be(new_connection) + + expect do + described_class.using_connection(second_connection) {} + end.to raise_error(/cannot nest connection overrides/) + + expect(described_class.connection).to be(new_connection) + end + end + end + end + context 'when the block raises an error', :aggregate_failures do it 're-raises the error, removing the overridden connection' do expect_original_connection_around do diff --git a/spec/lib/gitlab/database/unidirectional_copy_trigger_spec.rb b/spec/lib/gitlab/database/unidirectional_copy_trigger_spec.rb index 2955c208f16..bbddb5f1af5 100644 --- a/spec/lib/gitlab/database/unidirectional_copy_trigger_spec.rb +++ b/spec/lib/gitlab/database/unidirectional_copy_trigger_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::Database::UnidirectionalCopyTrigger do let(:table_name) { '_test_table' } let(:connection) { ActiveRecord::Base.connection } - let(:copy_trigger) { described_class.on_table(table_name) } + let(:copy_trigger) { described_class.on_table(table_name, connection: connection) } describe '#name' do context 'when a single column name is given' do diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index a2e7b6d27b9..5ec7c338a2a 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -15,13 +15,6 @@ RSpec.describe Gitlab::Database do end end - describe '.databases' do - it 'stores connections as a HashWithIndifferentAccess' do - expect(described_class.databases.has_key?('main')).to be true - expect(described_class.databases.has_key?(:main)).to be true - end - end - describe '.default_pool_size' do before do allow(Gitlab::Runtime).to receive(:max_threads).and_return(7) @@ -112,18 +105,30 @@ RSpec.describe Gitlab::Database do end describe '.check_postgres_version_and_print_warning' do + let(:reflect) { instance_spy(Gitlab::Database::Reflection) } + subject { described_class.check_postgres_version_and_print_warning } + before do + allow(Gitlab::Database::Reflection) + .to receive(:new) + .and_return(reflect) + end + it 'prints a warning if not compliant with minimum postgres version' do - allow(described_class.main).to receive(:postgresql_minimum_supported_version?).and_return(false) + allow(reflect).to receive(:postgresql_minimum_supported_version?).and_return(false) - expect(Kernel).to receive(:warn).with(/You are using PostgreSQL/) + expect(Kernel) + .to receive(:warn) + .with(/You are using PostgreSQL/) + .exactly(Gitlab::Database.database_base_models.length) + .times subject end it 'doesnt print a warning if compliant with minimum postgres version' do - allow(described_class.main).to receive(:postgresql_minimum_supported_version?).and_return(true) + allow(reflect).to receive(:postgresql_minimum_supported_version?).and_return(true) expect(Kernel).not_to receive(:warn).with(/You are using PostgreSQL/) @@ -131,7 +136,7 @@ RSpec.describe Gitlab::Database do end it 'doesnt print a warning in Rails runner environment' do - allow(described_class.main).to receive(:postgresql_minimum_supported_version?).and_return(false) + allow(reflect).to receive(:postgresql_minimum_supported_version?).and_return(false) allow(Gitlab::Runtime).to receive(:rails_runner?).and_return(true) expect(Kernel).not_to receive(:warn).with(/You are using PostgreSQL/) @@ -140,13 +145,13 @@ RSpec.describe Gitlab::Database do end it 'ignores ActiveRecord errors' do - allow(described_class.main).to receive(:postgresql_minimum_supported_version?).and_raise(ActiveRecord::ActiveRecordError) + allow(reflect).to receive(:postgresql_minimum_supported_version?).and_raise(ActiveRecord::ActiveRecordError) expect { subject }.not_to raise_error end it 'ignores Postgres errors' do - allow(described_class.main).to receive(:postgresql_minimum_supported_version?).and_raise(PG::Error) + allow(reflect).to receive(:postgresql_minimum_supported_version?).and_raise(PG::Error) expect { subject }.not_to raise_error end @@ -205,7 +210,7 @@ RSpec.describe Gitlab::Database do context 'when replicas are configured', :database_replica do it 'returns the name for a replica' do - replica = ActiveRecord::Base.connection.load_balancer.host + replica = ActiveRecord::Base.load_balancer.host expect(described_class.db_config_name(replica)).to eq('main_replica') end diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index 1800d2d6b60..4b437397688 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -51,6 +51,48 @@ RSpec.describe Gitlab::Diff::File do project.commit(branch_name).diffs.diff_files.first end + describe 'initialize' do + context 'when file is ipynb with a change after transformation' do + let(:commit) { project.commit("f6b7a707") } + let(:diff) { commit.raw_diffs.first } + let(:diff_file) { described_class.new(diff, diff_refs: commit.diff_refs, repository: project.repository) } + + context 'and :jupyter_clean_diffs is enabled' do + before do + stub_feature_flags(jupyter_clean_diffs: true) + end + + it 'recreates the diff by transforming the files' do + expect(diff_file.diff.diff).not_to include('"| Fake') + end + end + + context 'but :jupyter_clean_diffs is disabled' do + before do + stub_feature_flags(jupyter_clean_diffs: false) + end + + it 'does not recreate the diff' do + expect(diff_file.diff.diff).to include('"| Fake') + end + end + end + + context 'when file is ipynb, but there only changes that are removed' do + let(:commit) { project.commit("2b5ef814") } + let(:diff) { commit.raw_diffs.first } + let(:diff_file) { described_class.new(diff, diff_refs: commit.diff_refs, repository: project.repository) } + + before do + stub_feature_flags(jupyter_clean_diffs: true) + end + + it 'does not recreate the diff' do + expect(diff_file.diff.diff).to include('execution_count') + end + end + end + describe '#diff_lines' do let(:diff_lines) { diff_file.diff_lines } diff --git a/spec/lib/gitlab/diff/position_tracer/line_strategy_spec.rb b/spec/lib/gitlab/diff/position_tracer/line_strategy_spec.rb index bdeaabec1f1..b646cf38178 100644 --- a/spec/lib/gitlab/diff/position_tracer/line_strategy_spec.rb +++ b/spec/lib/gitlab/diff/position_tracer/line_strategy_spec.rb @@ -581,13 +581,16 @@ RSpec.describe Gitlab::Diff::PositionTracer::LineStrategy, :clean_gitlab_redis_c ) end - it "returns the new position but drops line_range information" do + it "returns the new position" do expect_change_position( old_path: file_name, new_path: file_name, old_line: nil, new_line: 2, - line_range: nil + line_range: { + "start_line_code" => 1, + "end_line_code" => 2 + } ) end end diff --git a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb index 8cb1ccc065b..c579027788d 100644 --- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb @@ -11,6 +11,7 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do end let(:email_raw) { email_fixture('emails/service_desk.eml') } + let(:author_email) { 'jake@adventuretime.ooo' } let_it_be(:group) { create(:group, :private, name: "email") } let(:expected_description) do @@ -45,7 +46,7 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do receiver.execute new_issue = Issue.last - expect(new_issue.issue_email_participants.first.email).to eq("jake@adventuretime.ooo") + expect(new_issue.issue_email_participants.first.email).to eq(author_email) end it 'sends thank you email' do @@ -196,60 +197,123 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do end end - context 'when using service desk key' do - let_it_be(:service_desk_key) { 'mykey' } + context 'when all lines of email are quoted' do + let(:email_raw) { email_fixture('emails/service_desk_all_quoted.eml') } - let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml') } + it 'creates email with correct body' do + receiver.execute + + issue = Issue.last + expect(issue.description).to include('> This is an empty quote') + end + end + + context 'when using custom service desk address' do let(:receiver) { Gitlab::Email::ServiceDeskReceiver.new(email_raw) } before do stub_service_desk_email_setting(enabled: true, address: 'support+%{key}@example.com') end - before_all do - create(:service_desk_setting, project: project, project_key: service_desk_key) - end + context 'when using project key' do + let_it_be(:service_desk_key) { 'mykey' } - it_behaves_like 'a new issue request' + let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml') } + + before_all do + create(:service_desk_setting, project: project, project_key: service_desk_key) + end + + it_behaves_like 'a new issue request' + + context 'when there is no project with the key' do + let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', key: 'some_key') } + + it 'bounces the email' do + expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + end + end + + context 'when the project slug does not match' do + let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: 'some-slug') } + + it 'bounces the email' do + expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + end + end + + context 'when there are multiple projects with same key' do + let_it_be(:project_with_same_key) { create(:project, group: group, service_desk_enabled: true) } + + let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: project_with_same_key.full_path_slug.to_s) } - context 'when there is no project with the key' do - let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', key: 'some_key') } + before do + create(:service_desk_setting, project: project_with_same_key, project_key: service_desk_key) + end - it 'bounces the email' do - expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + it 'process email for project with matching slug' do + expect { receiver.execute }.to change { Issue.count }.by(1) + expect(Issue.last.project).to eq(project_with_same_key) + end end end - context 'when the project slug does not match' do - let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: 'some-slug') } + context 'when project key is not set' do + let(:email_raw) { email_fixture('emails/service_desk_custom_address_no_key.eml') } - it 'bounces the email' do - expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + before do + stub_service_desk_email_setting(enabled: true, address: 'support+%{key}@example.com') end + + it_behaves_like 'a new issue request' end + end + end - context 'when there are multiple projects with same key' do - let_it_be(:project_with_same_key) { create(:project, group: group, service_desk_enabled: true) } + context 'when rate limiting is in effect', :freeze_time, :clean_gitlab_redis_rate_limiting do + let(:receiver) { Gitlab::Email::Receiver.new(email_raw) } - let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: project_with_same_key.full_path_slug.to_s) } + subject { 2.times { receiver.execute } } - before do - create(:service_desk_setting, project: project_with_same_key, project_key: service_desk_key) + before do + stub_feature_flags(rate_limited_service_issues_create: true) + stub_application_setting(issues_create_limit: 1) + end + + context 'when too many requests are sent by one user' do + it 'raises an error' do + expect { subject }.to raise_error(RateLimitedService::RateLimitedError) + end + + it 'creates 1 issue' do + expect do + subject + rescue RateLimitedService::RateLimitedError + end.to change { Issue.count }.by(1) + end + + context 'when requests are sent by different users' do + let(:email_raw_2) { email_fixture('emails/service_desk_forwarded.eml') } + let(:receiver2) { Gitlab::Email::Receiver.new(email_raw_2) } + + subject do + receiver.execute + receiver2.execute end - it 'process email for project with matching slug' do - expect { receiver.execute }.to change { Issue.count }.by(1) - expect(Issue.last.project).to eq(project_with_same_key) + it 'creates 2 issues' do + expect { subject }.to change { Issue.count }.by(2) end end end - context 'when rate limiting is in effect' do - it 'allows unlimited new issue creation' do - stub_application_setting(issues_create_limit: 1) - setup_attachment + context 'when limit is higher than sent emails' do + before do + stub_application_setting(issues_create_limit: 2) + end - expect { 2.times { receiver.execute } }.to change { Issue.count }.by(2) + it 'creates 2 issues' do + expect { subject }.to change { Issue.count }.by(2) end end end @@ -323,6 +387,7 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do end context 'when the email is forwarded through an alias' do + let(:author_email) { 'jake.g@adventuretime.ooo' } let(:email_raw) { email_fixture('emails/service_desk_forwarded.eml') } it_behaves_like 'a new issue request' diff --git a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb index 0a1f04ed793..352eb596cd9 100644 --- a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb +++ b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb @@ -36,7 +36,7 @@ RSpec.describe Gitlab::Email::Hook::SmimeSignatureInterceptor do end before do - allow(Gitlab::X509::Certificate).to receive_messages(from_files: certificate) + allow(Gitlab::Email::Hook::SmimeSignatureInterceptor).to receive(:certificate).and_return(certificate) Mail.register_interceptor(described_class) mail.deliver_now 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 277f1158f8b..0521123f1ef 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 @@ -82,4 +82,29 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Base do it { is_expected.to include('This is email 1 of 3 in the Create series', Gitlab::Routing.url_helpers.profile_notifications_url) } end end + + describe '#series?' do + using RSpec::Parameterized::TableSyntax + + subject do + test_class = "Gitlab::Email::Message::InProductMarketing::#{track.to_s.classify}".constantize + test_class.new(group: group, user: user, series: series).series? + end + + where(:track, :result) do + :create | true + :team_short | true + :trial_short | true + :admin_verify | true + :verify | true + :trial | true + :team | true + :experience | true + :invite_team | false + end + + with_them do + it { is_expected.to eq result } + end + end end diff --git a/spec/lib/gitlab/email/message/in_product_marketing/experience_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/experience_spec.rb index b742eff3f56..8cd2345822e 100644 --- a/spec/lib/gitlab/email/message/in_product_marketing/experience_spec.rb +++ b/spec/lib/gitlab/email/message/in_product_marketing/experience_spec.rb @@ -22,14 +22,36 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Experience do expect(message.cta_text).to be_nil end - describe '#feedback_link' do - let(:member_count) { 2 } + describe 'feedback URL' do + before do + allow(message).to receive(:onboarding_progress).and_return(1) + allow(message).to receive(:show_invite_link).and_return(true) + end + + subject do + message.feedback_link(1) + end + + it { is_expected.to start_with(Gitlab::Saas.com_url) } + + context 'when in development' do + let(:root_url) { 'http://example.com' } + + before do + allow(message).to receive(:root_url).and_return(root_url) + stub_rails_env('development') + end + + it { is_expected.to start_with(root_url) } + end + end + + describe 'feedback URL show_invite_link query param' do let(:user_access) { GroupMember::DEVELOPER } let(:preferred_language) { 'en' } before do allow(message).to receive(:onboarding_progress).and_return(1) - allow(group).to receive(:member_count).and_return(member_count) allow(group).to receive(:max_member_access_for_user).and_return(user_access) allow(user).to receive(:preferred_language).and_return(preferred_language) end @@ -41,12 +63,6 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Experience do it { is_expected.to eq('true') } - context 'with only one member' do - let(:member_count) { 1 } - - it { is_expected.to eq('false') } - end - context 'with less than developer access' do let(:user_access) { GroupMember::GUEST } @@ -59,6 +75,41 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Experience do it { is_expected.to eq('false') } end end + + describe 'feedback URL show_incentive query param' do + let(:show_invite_link) { true } + let(:member_count) { 2 } + let(:query) do + uri = URI.parse(message.feedback_link(1)) + Rack::Utils.parse_query(uri.query).with_indifferent_access + end + + before do + allow(message).to receive(:onboarding_progress).and_return(1) + allow(message).to receive(:show_invite_link).and_return(show_invite_link) + allow(group).to receive(:member_count).and_return(member_count) + end + + subject { query[:show_incentive] } + + it { is_expected.to eq('true') } + + context 'with only one member' do + let(:member_count) { 1 } + + it "is not present" do + expect(query).not_to have_key(:show_incentive) + end + end + + context 'show_invite_link is false' do + let(:show_invite_link) { false } + + it "is not present" do + expect(query).not_to have_key(:show_incentive) + end + end + end end end end 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 new file mode 100644 index 00000000000..8319560f594 --- /dev/null +++ b/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb @@ -0,0 +1,39 @@ +# 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 9ffc4a340a3..594df7440bb 100644 --- a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb +++ b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb @@ -10,10 +10,15 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing do context 'when track exists' do where(:track, :expected_class) do - :create | described_class::Create - :verify | described_class::Verify - :trial | described_class::Trial - :team | described_class::Team + :create | described_class::Create + :team_short | described_class::TeamShort + :trial_short | described_class::TrialShort + :admin_verify | described_class::AdminVerify + :verify | described_class::Verify + :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/email/reply_parser_spec.rb b/spec/lib/gitlab/email/reply_parser_spec.rb index 3b01b568fb4..c0d177aff4d 100644 --- a/spec/lib/gitlab/email/reply_parser_spec.rb +++ b/spec/lib/gitlab/email/reply_parser_spec.rb @@ -21,6 +21,30 @@ RSpec.describe Gitlab::Email::ReplyParser do expect(test_parse_body(fixture_file("emails/no_content_reply.eml"))).to eq("") end + context 'when allow_only_quotes is true' do + it "returns quoted text from email" do + text = test_parse_body(fixture_file("emails/no_content_reply.eml"), allow_only_quotes: true) + + expect(text).to eq( + <<-BODY.strip_heredoc.chomp + > + > + > + > eviltrout posted in 'Adventure Time Sux' on Discourse Meta: + > + > --- + > hey guys everyone knows adventure time sucks! + > + > --- + > Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3 + > + > To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences). + > + BODY + ) + end + end + it "properly renders plaintext-only email" do expect(test_parse_body(fixture_file("emails/plaintext_only.eml"))) .to eq( diff --git a/spec/lib/gitlab/emoji_spec.rb b/spec/lib/gitlab/emoji_spec.rb index 8f855489c12..0db3b5f3b11 100644 --- a/spec/lib/gitlab/emoji_spec.rb +++ b/spec/lib/gitlab/emoji_spec.rb @@ -3,90 +3,6 @@ require 'spec_helper' RSpec.describe Gitlab::Emoji do - let_it_be(:emojis) { Gemojione.index.instance_variable_get(:@emoji_by_name) } - let_it_be(:emojis_by_moji) { Gemojione.index.instance_variable_get(:@emoji_by_moji) } - let_it_be(:emoji_unicode_versions_by_name) { Gitlab::Json.parse(File.read(Rails.root.join('fixtures', 'emojis', 'emoji-unicode-version-map.json'))) } - let_it_be(:emojis_aliases) { Gitlab::Json.parse(File.read(Rails.root.join('fixtures', 'emojis', 'aliases.json'))) } - - describe '.emojis' do - it 'returns emojis' do - current_emojis = described_class.emojis - - expect(current_emojis).to eq(emojis) - end - end - - describe '.emojis_by_moji' do - it 'return emojis by moji' do - current_emojis_by_moji = described_class.emojis_by_moji - - expect(current_emojis_by_moji).to eq(emojis_by_moji) - end - end - - describe '.emojis_unicodes' do - it 'returns emoji unicodes' do - emoji_keys = described_class.emojis_unicodes - - expect(emoji_keys).to eq(emojis_by_moji.keys) - end - end - - describe '.emojis_names' do - it 'returns emoji names' do - emoji_names = described_class.emojis_names - - expect(emoji_names).to eq(emojis.keys) - end - end - - describe '.emojis_aliases' do - it 'returns emoji aliases' do - emoji_aliases = described_class.emojis_aliases - - expect(emoji_aliases).to eq(emojis_aliases) - end - end - - describe '.emoji_filename' do - it 'returns emoji filename' do - # "100" => {"unicode"=>"1F4AF"...} - emoji_filename = described_class.emoji_filename('100') - - expect(emoji_filename).to eq(emojis['100']['unicode']) - end - end - - describe '.emoji_unicode_filename' do - it 'returns emoji unicode filename' do - emoji_unicode_filename = described_class.emoji_unicode_filename('💯') - - expect(emoji_unicode_filename).to eq(emojis_by_moji['💯']['unicode']) - end - end - - describe '.emoji_unicode_version' do - it 'returns emoji unicode version by name' do - emoji_unicode_version = described_class.emoji_unicode_version('100') - - expect(emoji_unicode_version).to eq(emoji_unicode_versions_by_name['100']) - end - end - - describe '.normalize_emoji_name' do - it 'returns same name if not found in aliases' do - emoji_name = described_class.normalize_emoji_name('random') - - expect(emoji_name).to eq('random') - end - - it 'returns name if name found in aliases' do - emoji_name = described_class.normalize_emoji_name('small_airplane') - - expect(emoji_name).to eq(emojis_aliases['small_airplane']) - end - end - describe '.emoji_image_tag' do it 'returns emoji image tag' do emoji_image = described_class.emoji_image_tag('emoji_one', 'src_url') @@ -104,29 +20,17 @@ RSpec.describe Gitlab::Emoji do end end - describe '.emoji_exists?' do - it 'returns true if the name exists' do - emoji_exists = described_class.emoji_exists?('100') - - expect(emoji_exists).to be_truthy - end - - it 'returns false if the name does not exist' do - emoji_exists = described_class.emoji_exists?('random') - - expect(emoji_exists).to be_falsey - end - end - describe '.gl_emoji_tag' do it 'returns gl emoji tag if emoji is found' do - gl_tag = described_class.gl_emoji_tag('small_airplane') + emoji = TanukiEmoji.find_by_alpha_code('small_airplane') + gl_tag = described_class.gl_emoji_tag(emoji) expect(gl_tag).to eq('🛩') end - it 'returns nil if emoji name is not found' do - gl_tag = described_class.gl_emoji_tag('random') + it 'returns nil if emoji is not found' do + emoji = TanukiEmoji.find_by_alpha_code('random') + gl_tag = described_class.gl_emoji_tag(emoji) expect(gl_tag).to be_nil end diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb index c4da89e5f5c..982c0d911bc 100644 --- a/spec/lib/gitlab/etag_caching/middleware_spec.rb +++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb @@ -174,7 +174,7 @@ RSpec.describe Gitlab::EtagCaching::Middleware, :clean_gitlab_redis_shared_state it "pushes route's feature category to the context" do expect(Gitlab::ApplicationContext).to receive(:push).with( - feature_category: 'issue_tracking' + feature_category: 'team_planning' ) _, _, _ = middleware.call(build_request(path, if_none_match)) diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb index f4dba5e8d58..11510daf9c0 100644 --- a/spec/lib/gitlab/git/commit_spec.rb +++ b/spec/lib/gitlab/git/commit_spec.rb @@ -715,6 +715,14 @@ RSpec.describe Gitlab::Git::Commit, :seed_helper do it { is_expected.not_to include("feature") } end + describe '#first_ref_by_oid' do + let(:commit) { described_class.find(repository, 'master') } + + subject { commit.first_ref_by_oid(repository) } + + it { is_expected.to eq("master") } + end + describe '.get_message' do let(:commit_ids) { %w[6d394385cf567f80a8fd85055db1ab4c5295806f cfe32cf61b73a0d5e9f13e774abde7ff789b1660] } diff --git a/spec/lib/gitlab/git/object_pool_spec.rb b/spec/lib/gitlab/git/object_pool_spec.rb index e1873c6ddb5..91960ebbede 100644 --- a/spec/lib/gitlab/git/object_pool_spec.rb +++ b/spec/lib/gitlab/git/object_pool_spec.rb @@ -112,7 +112,7 @@ RSpec.describe Gitlab::Git::ObjectPool do subject.fetch - expect(subject.repository.commit_count('refs/remotes/origin/master')).to eq(commit_count) + expect(subject.repository.commit_count('refs/remotes/origin/heads/master')).to eq(commit_count) expect(subject.repository.commit(new_commit_id).id).to eq(new_commit_id) end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index c7b68ff3e28..f1b6a59abf9 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -125,7 +125,22 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do it 'gets tags from GitalyClient' do expect_next_instance_of(Gitlab::GitalyClient::RefService) do |service| - expect(service).to receive(:tags).with(sort_by: 'name_asc') + expect(service).to receive(:tags).with(sort_by: 'name_asc', pagination_params: nil) + end + + subject + end + end + + context 'with pagination option' do + subject { repository.tags(pagination_params: { limit: 5, page_token: 'refs/tags/v1.0.0' }) } + + it 'gets tags from GitalyClient' do + expect_next_instance_of(Gitlab::GitalyClient::RefService) do |service| + expect(service).to receive(:tags).with( + sort_by: nil, + pagination_params: { limit: 5, page_token: 'refs/tags/v1.0.0' } + ) end subject @@ -1888,6 +1903,44 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do end end + describe '#list_refs' do + it 'returns a list of branches with their head commit' do + refs = repository.list_refs + reference = refs.first + + expect(refs).to be_an(Enumerable) + expect(reference).to be_a(Gitaly::ListRefsResponse::Reference) + expect(reference.name).to be_a(String) + expect(reference.target).to be_a(String) + end + end + + describe '#refs_by_oid' do + it 'returns a list of refs from a OID' do + refs = repository.refs_by_oid(oid: repository.commit.id) + + expect(refs).to be_an(Array) + expect(refs).to include(Gitlab::Git::BRANCH_REF_PREFIX + repository.root_ref) + end + + it 'returns a single ref from a OID' do + refs = repository.refs_by_oid(oid: repository.commit.id, limit: 1) + + expect(refs).to be_an(Array) + expect(refs).to eq([Gitlab::Git::BRANCH_REF_PREFIX + repository.root_ref]) + end + + it 'returns empty for unknown ID' do + expect(repository.refs_by_oid(oid: Gitlab::Git::BLANK_SHA, limit: 0)).to eq([]) + end + + it 'returns nil for an empty repo' do + project = create(:project) + + expect(project.repository.refs_by_oid(oid: SeedRepo::Commit::ID, limit: 0)).to be_nil + end + end + describe '#set_full_path' do before do repository_rugged.config["gitlab.fullpath"] = repository_path diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index 554a91f2bc5..d8e397dd6f3 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -112,15 +112,38 @@ RSpec.describe Gitlab::GitalyClient::CommitService do let(:from) { 'master' } let(:to) { Gitlab::Git::EMPTY_TREE_ID } - it 'sends an RPC request' do - request = Gitaly::CommitsBetweenRequest.new( - repository: repository_message, from: from, to: to - ) + context 'with between_commits_via_list_commits enabled' do + before do + stub_feature_flags(between_commits_via_list_commits: true) + end - expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:commits_between) - .with(request, kind_of(Hash)).and_return([]) + it 'sends an RPC request' do + request = Gitaly::ListCommitsRequest.new( + repository: repository_message, revisions: ["^" + from, to], reverse: true + ) + + expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:list_commits) + .with(request, kind_of(Hash)).and_return([]) - described_class.new(repository).between(from, to) + described_class.new(repository).between(from, to) + end + end + + context 'with between_commits_via_list_commits disabled' do + before do + stub_feature_flags(between_commits_via_list_commits: false) + end + + it 'sends an RPC request' do + request = Gitaly::CommitsBetweenRequest.new( + repository: repository_message, from: from, to: to + ) + + expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:commits_between) + .with(request, kind_of(Hash)).and_return([]) + + described_class.new(repository).between(from, to) + end end end diff --git a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb index d308612ef31..2e37c98a591 100644 --- a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb @@ -190,6 +190,22 @@ RSpec.describe Gitlab::GitalyClient::RefService do client.tags(sort_by: 'name_asc') end end + + context 'with pagination option' do + it 'sends a correct find_all_tags message' do + expected_pagination = Gitaly::PaginationParameter.new( + limit: 5, + page_token: 'refs/tags/v1.0.0' + ) + + expect_any_instance_of(Gitaly::RefService::Stub) + .to receive(:find_all_tags) + .with(gitaly_request_with_params(pagination_params: expected_pagination), kind_of(Hash)) + .and_return([]) + + client.tags(pagination_params: { limit: 5, page_token: 'refs/tags/v1.0.0' }) + end + end end describe '#branch_names_contains_sha' do @@ -252,6 +268,26 @@ RSpec.describe Gitlab::GitalyClient::RefService do end end + describe '#list_refs' do + it 'sends a list_refs message' do + expect_any_instance_of(Gitaly::RefService::Stub) + .to receive(:list_refs) + .with(gitaly_request_with_params(patterns: ['refs/heads/']), kind_of(Hash)) + .and_call_original + + client.list_refs + end + + it 'accepts a patterns argument' do + expect_any_instance_of(Gitaly::RefService::Stub) + .to receive(:list_refs) + .with(gitaly_request_with_params(patterns: ['refs/tags/']), kind_of(Hash)) + .and_call_original + + client.list_refs([Gitlab::Git::TAG_REF_PREFIX]) + end + end + describe '#pack_refs' do it 'sends a pack_refs message' do expect_any_instance_of(Gitaly::RefService::Stub) @@ -262,4 +298,19 @@ RSpec.describe Gitlab::GitalyClient::RefService do client.pack_refs end end + + describe '#find_refs_by_oid' do + let(:oid) { project.repository.commit.id } + + it 'sends a find_refs_by_oid message' do + expect_any_instance_of(Gitaly::RefService::Stub) + .to receive(:find_refs_by_oid) + .with(gitaly_request_with_params(sort_field: 'refname', oid: oid, limit: 1), kind_of(Hash)) + .and_call_original + + refs = client.find_refs_by_oid(oid: oid, limit: 1) + + expect(refs.to_a).to eq([Gitlab::Git::BRANCH_REF_PREFIX + project.repository.root_ref]) + end + end end diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb index 16f75691288..ba4ea1069d8 100644 --- a/spec/lib/gitlab/gitaly_client_spec.rb +++ b/spec/lib/gitlab/gitaly_client_spec.rb @@ -5,14 +5,6 @@ require 'spec_helper' # We stub Gitaly in `spec/support/gitaly.rb` for other tests. We don't want # those stubs while testing the GitalyClient itself. RSpec.describe Gitlab::GitalyClient do - let(:sample_cert) { Rails.root.join('spec/fixtures/clusters/sample_cert.pem').to_s } - - before do - allow(described_class) - .to receive(:stub_cert_paths) - .and_return([sample_cert]) - end - def stub_repos_storages(address) allow(Gitlab.config.repositories).to receive(:storages).and_return({ 'default' => { 'gitaly_address' => address } @@ -142,21 +134,6 @@ RSpec.describe Gitlab::GitalyClient do end end - describe '.stub_certs' do - it 'skips certificates if OpenSSLError is raised and report it' do - expect(Gitlab::ErrorTracking) - .to receive(:track_and_raise_for_dev_exception) - .with( - a_kind_of(OpenSSL::X509::CertificateError), - cert_file: a_kind_of(String)).at_least(:once) - - expect(OpenSSL::X509::Certificate) - .to receive(:new) - .and_raise(OpenSSL::X509::CertificateError).at_least(:once) - - expect(described_class.stub_certs).to be_a(String) - end - end describe '.stub_creds' do it 'returns :this_channel_is_insecure if unix' do address = 'unix:/tmp/gitaly.sock' diff --git a/spec/lib/gitlab/github_import/bulk_importing_spec.rb b/spec/lib/gitlab/github_import/bulk_importing_spec.rb index 6c94973b5a8..e170496ff7b 100644 --- a/spec/lib/gitlab/github_import/bulk_importing_spec.rb +++ b/spec/lib/gitlab/github_import/bulk_importing_spec.rb @@ -116,13 +116,13 @@ RSpec.describe Gitlab::GithubImport::BulkImporting do value: 5 ) - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .ordered .with('kittens', rows.first(5)) - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .ordered .with('kittens', rows.last(5)) diff --git a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb index 3dc15c7c059..0448ada6bca 100644 --- a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb @@ -2,156 +2,226 @@ require 'spec_helper' -RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter do - let(:project) { create(:project) } - let(:client) { double(:client) } - let(:user) { create(:user) } - let(:created_at) { Time.new(2017, 1, 1, 12, 00) } - let(:updated_at) { Time.new(2017, 1, 1, 12, 15) } +RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter, :aggregate_failures do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } - let(:hunk) do - '@@ -1 +1 @@ + let(:client) { double(:client) } + let(:discussion_id) { 'b0fa404393eeebb4e82becb8104f238812bb1fe6' } + let(:created_at) { Time.new(2017, 1, 1, 12, 00).utc } + let(:updated_at) { Time.new(2017, 1, 1, 12, 15).utc } + let(:note_body) { 'Hello' } + let(:file_path) { 'files/ruby/popen.rb' } + + let(:diff_hunk) do + '@@ -14 +14 @@ -Hello +Hello world' end - let(:note) do + let(:note_representation) do Gitlab::GithubImport::Representation::DiffNote.new( noteable_type: 'MergeRequest', noteable_id: 1, commit_id: '123abc', original_commit_id: 'original123abc', - file_path: 'README.md', - diff_hunk: hunk, - author: Gitlab::GithubImport::Representation::User - .new(id: user.id, login: user.username), - note: 'Hello', + file_path: file_path, + author: Gitlab::GithubImport::Representation::User.new(id: user.id, login: user.username), + note: note_body, created_at: created_at, updated_at: updated_at, - github_id: 1 + start_line: nil, + end_line: 15, + github_id: 1, + diff_hunk: diff_hunk, + side: 'RIGHT' ) end - let(:importer) { described_class.new(note, project, client) } + subject(:importer) { described_class.new(note_representation, project, client) } + + shared_examples 'diff notes without suggestion' do + it 'imports the note as legacy diff note' do + stub_user_finder(user.id, true) + + expect { subject.execute } + .to change(LegacyDiffNote, :count) + .by(1) + + note = project.notes.diff_notes.take + expect(note).to be_valid + expect(note.author_id).to eq(user.id) + expect(note.commit_id).to eq('original123abc') + expect(note.created_at).to eq(created_at) + expect(note.diff).to be_an_instance_of(Gitlab::Git::Diff) + expect(note.discussion_id).to eq(discussion_id) + expect(note.line_code).to eq(note_representation.line_code) + expect(note.note).to eq('Hello') + expect(note.noteable_id).to eq(merge_request.id) + expect(note.noteable_type).to eq('MergeRequest') + expect(note.project_id).to eq(project.id) + expect(note.st_diff).to eq(note_representation.diff_hash) + expect(note.system).to eq(false) + expect(note.type).to eq('LegacyDiffNote') + expect(note.updated_at).to eq(updated_at) + end + + it 'adds a "created by:" note when the author cannot be found' do + stub_user_finder(project.creator_id, false) + + expect { subject.execute } + .to change(LegacyDiffNote, :count) + .by(1) + + note = project.notes.diff_notes.take + expect(note).to be_valid + expect(note.author_id).to eq(project.creator_id) + expect(note.note).to eq("*Created by: #{user.username}*\n\nHello") + end + + it 'does not import the note when a foreign key error is raised' do + stub_user_finder(project.creator_id, false) + + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) + .and_raise(ActiveRecord::InvalidForeignKey, 'invalid foreign key') + + expect { subject.execute } + .not_to change(LegacyDiffNote, :count) + end + end describe '#execute' do context 'when the merge request no longer exists' do it 'does not import anything' do - expect(Gitlab::Database.main).not_to receive(:bulk_insert) + expect(ApplicationRecord).not_to receive(:legacy_bulk_insert) - importer.execute + expect { subject.execute } + .to not_change(DiffNote, :count) + .and not_change(LegacyDiffNote, :count) end end context 'when the merge request exists' do - let!(:merge_request) do + let_it_be(:merge_request) do create(:merge_request, source_project: project, target_project: project) end before do - allow(importer) - .to receive(:find_merge_request_id) - .and_return(merge_request.id) + expect_next_instance_of(Gitlab::GithubImport::IssuableFinder) do |finder| + expect(finder) + .to receive(:database_id) + .and_return(merge_request.id) + end + + expect(Discussion) + .to receive(:discussion_id) + .and_return(discussion_id) end - it 'imports the note' do - allow(importer.user_finder) - .to receive(:author_id_for) - .and_return([user.id, true]) - - expect(Gitlab::Database.main) - .to receive(:bulk_insert) - .with( - LegacyDiffNote.table_name, - [ - { - discussion_id: anything, - noteable_type: 'MergeRequest', - noteable_id: merge_request.id, - project_id: project.id, - author_id: user.id, - note: 'Hello', - system: false, - commit_id: 'original123abc', - line_code: note.line_code, - type: 'LegacyDiffNote', - created_at: created_at, - updated_at: updated_at, - st_diff: note.diff_hash.to_yaml - } - ] - ) - .and_call_original - - importer.execute + context 'when github_importer_use_diff_note_with_suggestions is disabled' do + before do + stub_feature_flags(github_importer_use_diff_note_with_suggestions: false) + end + + it_behaves_like 'diff notes without suggestion' + + context 'when the note has suggestions' do + let(:note_body) do + <<~EOB + Suggestion: + ```suggestion + what do you think to do it like this + ``` + EOB + end + + it 'imports the note' do + stub_user_finder(user.id, true) + + expect { subject.execute } + .to change(LegacyDiffNote, :count) + .and not_change(DiffNote, :count) + + note = project.notes.diff_notes.take + expect(note).to be_valid + expect(note.note) + .to eq <<~NOTE + Suggestion: + ```suggestion:-0+0 + what do you think to do it like this + ``` + NOTE + end + end end - it 'imports the note when the author could not be found' do - allow(importer.user_finder) - .to receive(:author_id_for) - .and_return([project.creator_id, false]) - - expect(Gitlab::Database.main) - .to receive(:bulk_insert) - .with( - LegacyDiffNote.table_name, - [ - { - discussion_id: anything, - noteable_type: 'MergeRequest', - noteable_id: merge_request.id, - project_id: project.id, - author_id: project.creator_id, - note: "*Created by: #{user.username}*\n\nHello", - system: false, - commit_id: 'original123abc', - line_code: note.line_code, - type: 'LegacyDiffNote', - created_at: created_at, - updated_at: updated_at, - st_diff: note.diff_hash.to_yaml - } - ] - ) - .and_call_original - - importer.execute - end - - it 'produces a valid LegacyDiffNote' do - allow(importer.user_finder) - .to receive(:author_id_for) - .and_return([user.id, true]) - - importer.execute - - note = project.notes.diff_notes.take - - expect(note).to be_valid - expect(note.diff).to be_an_instance_of(Gitlab::Git::Diff) - end - - it 'does not import the note when a foreign key error is raised' do - allow(importer.user_finder) - .to receive(:author_id_for) - .and_return([project.creator_id, false]) - - expect(Gitlab::Database.main) - .to receive(:bulk_insert) - .and_raise(ActiveRecord::InvalidForeignKey, 'invalid foreign key') - - expect { importer.execute }.not_to raise_error + context 'when github_importer_use_diff_note_with_suggestions is enabled' do + before do + stub_feature_flags(github_importer_use_diff_note_with_suggestions: true) + end + + it_behaves_like 'diff notes without suggestion' + + context 'when the note has suggestions' do + let(:note_body) do + <<~EOB + Suggestion: + ```suggestion + what do you think to do it like this + ``` + EOB + end + + it 'imports the note as diff note' do + stub_user_finder(user.id, true) + + expect { subject.execute } + .to change(DiffNote, :count) + .by(1) + + note = project.notes.diff_notes.take + expect(note).to be_valid + expect(note.noteable_type).to eq('MergeRequest') + expect(note.noteable_id).to eq(merge_request.id) + expect(note.project_id).to eq(project.id) + expect(note.author_id).to eq(user.id) + expect(note.system).to eq(false) + expect(note.discussion_id).to eq(discussion_id) + expect(note.commit_id).to eq('original123abc') + expect(note.line_code).to eq(note_representation.line_code) + expect(note.type).to eq('DiffNote') + expect(note.created_at).to eq(created_at) + expect(note.updated_at).to eq(updated_at) + expect(note.position.to_h).to eq({ + base_sha: merge_request.diffs.diff_refs.base_sha, + head_sha: merge_request.diffs.diff_refs.head_sha, + start_sha: merge_request.diffs.diff_refs.start_sha, + new_line: 15, + old_line: nil, + new_path: file_path, + old_path: file_path, + position_type: 'text', + line_range: nil + }) + expect(note.note) + .to eq <<~NOTE + Suggestion: + ```suggestion:-0+0 + what do you think to do it like this + ``` + NOTE + end + end end end end - describe '#find_merge_request_id' do - it 'returns a merge request ID' do - expect_next_instance_of(Gitlab::GithubImport::IssuableFinder) do |instance| - expect(instance).to receive(:database_id).and_return(10) - end - - expect(importer.find_merge_request_id).to eq(10) + def stub_user_finder(user, found) + expect_next_instance_of(Gitlab::GithubImport::UserFinder) do |finder| + expect(finder) + .to receive(:author_id_for) + .and_return([user, found]) 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 be4fc3cbf16..1c7b35ed928 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 @@ -19,7 +19,9 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do updated_at: Time.zone.now, line: 23, start_line: nil, + in_reply_to_id: nil, id: 1, + side: 'RIGHT', body: <<~BODY Hello World diff --git a/spec/lib/gitlab/github_import/importer/issue_importer_spec.rb b/spec/lib/gitlab/github_import/importer/issue_importer_spec.rb index 0926000428c..4287c32b947 100644 --- a/spec/lib/gitlab/github_import/importer/issue_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/issue_importer_spec.rb @@ -190,8 +190,8 @@ RSpec.describe Gitlab::GithubImport::Importer::IssueImporter, :clean_gitlab_redi .with(issue.assignees[1]) .and_return(5) - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .with( IssueAssignee.table_name, [{ issue_id: 1, user_id: 4 }, { issue_id: 1, user_id: 5 }] diff --git a/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb b/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb index 241a0fef600..e68849755b2 100644 --- a/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/label_links_importer_spec.rb @@ -39,8 +39,8 @@ RSpec.describe Gitlab::GithubImport::Importer::LabelLinksImporter do .and_return(1) freeze_time do - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .with( LabelLink.table_name, [ @@ -64,8 +64,8 @@ RSpec.describe Gitlab::GithubImport::Importer::LabelLinksImporter do .with('bug') .and_return(nil) - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .with(LabelLink.table_name, []) importer.create_labels diff --git a/spec/lib/gitlab/github_import/importer/note_importer_spec.rb b/spec/lib/gitlab/github_import/importer/note_importer_spec.rb index 820f46c7286..96d8acbd3de 100644 --- a/spec/lib/gitlab/github_import/importer/note_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/note_importer_spec.rb @@ -41,8 +41,8 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteImporter do .with(github_note) .and_return([user.id, true]) - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .with( Note.table_name, [ @@ -71,8 +71,8 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteImporter do .with(github_note) .and_return([project.creator_id, false]) - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .with( Note.table_name, [ @@ -115,7 +115,7 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteImporter do context 'when the noteable does not exist' do it 'does not import the note' do - expect(Gitlab::Database.main).not_to receive(:bulk_insert) + expect(ApplicationRecord).not_to receive(:legacy_bulk_insert) importer.execute end @@ -134,8 +134,8 @@ RSpec.describe Gitlab::GithubImport::Importer::NoteImporter do .with(github_note) .and_return([user.id, true]) - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .and_raise(ActiveRecord::InvalidForeignKey, 'invalid foreign key') expect { importer.execute }.not_to raise_error diff --git a/spec/lib/gitlab/github_import/importer/pull_requests_merged_by_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests_merged_by_importer_spec.rb index 4a47d103cde..b6c162aafa9 100644 --- a/spec/lib/gitlab/github_import/importer/pull_requests_merged_by_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/pull_requests_merged_by_importer_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::Importer::PullRequestsMergedByImporter do let(:client) { double } - let(:project) { create(:project, import_source: 'http://somegithub.com') } + + let_it_be(:project) { create(:project, import_source: 'http://somegithub.com') } subject { described_class.new(project, client) } @@ -27,14 +28,11 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestsMergedByImporter do end describe '#each_object_to_import', :clean_gitlab_redis_cache do - it 'fetchs the merged pull requests data' do - create( - :merged_merge_request, - iid: 999, - source_project: project, - target_project: project - ) + let!(:merge_request) do + create(:merged_merge_request, iid: 999, source_project: project, target_project: project) + end + it 'fetches the merged pull requests data' do pull_request = double allow(client) @@ -48,5 +46,16 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestsMergedByImporter do subject.each_object_to_import {} end + + it 'skips cached merge requests' do + Gitlab::Cache::Import::Caching.set_add( + "github-importer/already-imported/#{project.id}/pull_requests_merged_by", + merge_request.id + ) + + expect(client).not_to receive(:pull_request) + + subject.each_object_to_import {} + end end end diff --git a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb index 81722c0eba7..63834cfdb94 100644 --- a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb +++ b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb @@ -2,23 +2,44 @@ require 'spec_helper' -RSpec.describe Gitlab::GithubImport::Representation::DiffNote do +RSpec.describe Gitlab::GithubImport::Representation::DiffNote, :clean_gitlab_redis_shared_state do let(:hunk) do '@@ -1 +1 @@ -Hello +Hello world' end + let(:merge_request) do + double( + :merge_request, + id: 54, + diff_refs: double( + :refs, + base_sha: 'base', + start_sha: 'start', + head_sha: 'head' + ) + ) + end + + let(:project) { double(:project, id: 836) } + let(:note_id) { 1 } + let(:in_reply_to_id) { nil } + let(:start_line) { nil } + let(:end_line) { 23 } + let(:note_body) { 'Hello world' } + let(:user_data) { { 'id' => 4, 'login' => 'alice' } } + let(:side) { 'RIGHT' } let(:created_at) { Time.new(2017, 1, 1, 12, 00) } let(:updated_at) { Time.new(2017, 1, 1, 12, 15) } - shared_examples 'a DiffNote' do + shared_examples 'a DiffNote representation' do it 'returns an instance of DiffNote' do expect(note).to be_an_instance_of(described_class) end context 'the returned DiffNote' do - it 'includes the number of the note' do + it 'includes the number of the merge request' do expect(note.noteable_id).to eq(42) end @@ -30,18 +51,6 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do expect(note.commit_id).to eq('123abc') end - it 'includes the user details' do - expect(note.author) - .to be_an_instance_of(Gitlab::GithubImport::Representation::User) - - expect(note.author.id).to eq(4) - expect(note.author.login).to eq('alice') - end - - it 'includes the note body' do - expect(note.note).to eq('Hello world') - end - it 'includes the created timestamp' do expect(note.created_at).to eq(created_at) end @@ -51,209 +60,250 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do end it 'includes the GitHub ID' do - expect(note.note_id).to eq(1) + expect(note.note_id).to eq(note_id) end it 'returns the noteable type' do expect(note.noteable_type).to eq('MergeRequest') end - end - end - - describe '.from_api_response' do - let(:response) do - double( - :response, - html_url: 'https://github.com/foo/bar/pull/42', - path: 'README.md', - commit_id: '123abc', - original_commit_id: 'original123abc', - diff_hunk: hunk, - user: double(:user, id: 4, login: 'alice'), - body: 'Hello world', - created_at: created_at, - updated_at: updated_at, - line: 23, - start_line: nil, - id: 1 - ) - end - - it_behaves_like 'a DiffNote' do - let(:note) { described_class.from_api_response(response) } - end - - it 'does not set the user if the response did not include a user' do - allow(response) - .to receive(:user) - .and_return(nil) - - note = described_class.from_api_response(response) - - expect(note.author).to be_nil - end - - it 'formats a suggestion in the note body' do - allow(response) - .to receive(:body) - .and_return <<~BODY - ```suggestion - Hello World - ``` - BODY - - note = described_class.from_api_response(response) - - expect(note.note).to eq <<~BODY - ```suggestion:-0+0 - Hello World - ``` - BODY - end - end - - describe '.from_json_hash' do - let(:hash) do - { - 'noteable_type' => 'MergeRequest', - 'noteable_id' => 42, - 'file_path' => 'README.md', - 'commit_id' => '123abc', - 'original_commit_id' => 'original123abc', - 'diff_hunk' => hunk, - 'author' => { 'id' => 4, 'login' => 'alice' }, - 'note' => 'Hello world', - 'created_at' => created_at.to_s, - 'updated_at' => updated_at.to_s, - 'note_id' => 1 - } - end - it_behaves_like 'a DiffNote' do - let(:note) { described_class.from_json_hash(hash) } - end - - it 'does not convert the author if it was not specified' do - hash.delete('author') - - note = described_class.from_json_hash(hash) + describe '#diff_hash' do + it 'returns a Hash containing the diff details' do + expect(note.diff_hash).to eq( + diff: hunk, + new_path: 'README.md', + old_path: 'README.md', + a_mode: '100644', + b_mode: '100644', + new_file: false + ) + end + end - expect(note.author).to be_nil - end + describe '#diff_position' do + before do + note.merge_request = double( + :merge_request, + diff_refs: double( + :refs, + base_sha: 'base', + start_sha: 'start', + head_sha: 'head' + ) + ) + end + + context 'when the diff is an addition' do + it 'returns a Gitlab::Diff::Position' do + expect(note.diff_position.to_h).to eq( + base_sha: 'base', + head_sha: 'head', + line_range: nil, + new_line: 23, + new_path: 'README.md', + old_line: nil, + old_path: 'README.md', + position_type: 'text', + start_sha: 'start' + ) + end + end + + context 'when the diff is an deletion' do + let(:side) { 'LEFT' } + + it 'returns a Gitlab::Diff::Position' do + expect(note.diff_position.to_h).to eq( + base_sha: 'base', + head_sha: 'head', + line_range: nil, + old_line: 23, + new_path: 'README.md', + new_line: nil, + old_path: 'README.md', + position_type: 'text', + start_sha: 'start' + ) + end + end + end - it 'formats a suggestion in the note body' do - hash['note'] = <<~BODY - ```suggestion - Hello World - ``` - BODY + describe '#discussion_id' do + before do + note.project = project + note.merge_request = merge_request + end + + context 'when the note is a reply to a discussion' do + it 'uses the cached value as the discussion_id only when responding an existing discussion' do + expect(Discussion) + .to receive(:discussion_id) + .and_return('FIRST_DISCUSSION_ID', 'SECOND_DISCUSSION_ID') + + # Creates the first discussion id and caches its value + expect(note.discussion_id) + .to eq('FIRST_DISCUSSION_ID') + + reply_note = described_class.from_json_hash( + 'note_id' => note.note_id + 1, + 'in_reply_to_id' => note.note_id + ) + reply_note.project = project + reply_note.merge_request = merge_request + + # Reading from the cached value + expect(reply_note.discussion_id) + .to eq('FIRST_DISCUSSION_ID') + + new_discussion_note = described_class.from_json_hash( + 'note_id' => note.note_id + 2, + 'in_reply_to_id' => nil + ) + new_discussion_note.project = project + new_discussion_note.merge_request = merge_request + + # Because it's a new discussion, it must not use the cached value + expect(new_discussion_note.discussion_id) + .to eq('SECOND_DISCUSSION_ID') + end + end + end - note = described_class.from_json_hash(hash) + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + expect(note.github_identifiers).to eq( + noteable_id: 42, + noteable_type: 'MergeRequest', + note_id: 1 + ) + end + end - expect(note.note).to eq <<~BODY - ```suggestion:-0+0 - Hello World - ``` - BODY - end - end + describe '#line_code' do + it 'generates the proper line code' do + note = described_class.new(diff_hunk: hunk, file_path: 'README.md') - describe '#line_code' do - it 'returns a String' do - note = described_class.new(diff_hunk: hunk, file_path: 'README.md') + expect(note.line_code).to eq('8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_2_2') + end + end - expect(note.line_code).to be_an_instance_of(String) + describe '#note and #contains_suggestion?' do + it 'includes the note body' do + expect(note.note).to eq('Hello world') + expect(note.contains_suggestion?).to eq(false) + end + + context 'when the note have a suggestion' do + let(:note_body) do + <<~BODY + ```suggestion + Hello World + ``` + BODY + end + + it 'returns the suggestion formatted in the note' do + expect(note.note).to eq <<~BODY + ```suggestion:-0+0 + Hello World + ``` + BODY + expect(note.contains_suggestion?).to eq(true) + end + end + + context 'when the note have a multiline suggestion' do + let(:start_line) { 20 } + let(:end_line) { 23 } + let(:note_body) do + <<~BODY + ```suggestion + Hello World + ``` + BODY + end + + it 'returns the multi-line suggestion formatted in the note' do + expect(note.note).to eq <<~BODY + ```suggestion:-3+0 + Hello World + ``` + BODY + expect(note.contains_suggestion?).to eq(true) + end + end + + describe '#author' do + it 'includes the user details' do + expect(note.author).to be_an_instance_of( + Gitlab::GithubImport::Representation::User + ) + + expect(note.author.id).to eq(4) + expect(note.author.login).to eq('alice') + end + + context 'when the author is empty' do + let(:user_data) { nil } + + it 'does not set the user if the response did not include a user' do + expect(note.author).to be_nil + end + end + end + end end end - describe '#diff_hash' do - it 'returns a Hash containing the diff details' do - note = described_class.from_json_hash( - 'noteable_type' => 'MergeRequest', - 'noteable_id' => 42, - 'file_path' => 'README.md', - 'commit_id' => '123abc', - 'original_commit_id' => 'original123abc', - 'diff_hunk' => hunk, - 'author' => { 'id' => 4, 'login' => 'alice' }, - 'note' => 'Hello world', - 'created_at' => created_at.to_s, - 'updated_at' => updated_at.to_s, - 'note_id' => 1 - ) - - expect(note.diff_hash).to eq( - diff: hunk, - new_path: 'README.md', - old_path: 'README.md', - a_mode: '100644', - b_mode: '100644', - new_file: false - ) - end - end + describe '.from_api_response' do + it_behaves_like 'a DiffNote representation' do + let(:response) do + double( + :response, + id: note_id, + html_url: 'https://github.com/foo/bar/pull/42', + path: 'README.md', + commit_id: '123abc', + original_commit_id: 'original123abc', + side: side, + user: user_data && double(:user, user_data), + diff_hunk: hunk, + body: note_body, + created_at: created_at, + updated_at: updated_at, + line: end_line, + start_line: start_line, + in_reply_to_id: in_reply_to_id + ) + end - describe '#github_identifiers' do - it 'returns a hash with needed identifiers' do - github_identifiers = { - noteable_id: 42, - noteable_type: 'MergeRequest', - note_id: 1 - } - other_attributes = { something_else: '_something_else_' } - note = described_class.new(github_identifiers.merge(other_attributes)) - - expect(note.github_identifiers).to eq(github_identifiers) + subject(:note) { described_class.from_api_response(response) } end end - describe '#note' do - it 'returns the given note' do - hash = { - 'note': 'simple text' - } - - note = described_class.new(hash) - - expect(note.note).to eq 'simple text' - end - - it 'returns the suggestion formatted in the note' do - hash = { - 'note': <<~BODY - ```suggestion - Hello World - ``` - BODY - } - - note = described_class.new(hash) - - expect(note.note).to eq <<~BODY - ```suggestion:-0+0 - Hello World - ``` - BODY - end + describe '.from_json_hash' do + it_behaves_like 'a DiffNote representation' do + let(:hash) do + { + 'note_id' => note_id, + 'noteable_type' => 'MergeRequest', + 'noteable_id' => 42, + 'file_path' => 'README.md', + 'commit_id' => '123abc', + 'original_commit_id' => 'original123abc', + 'side' => side, + 'author' => user_data, + 'diff_hunk' => hunk, + 'note' => note_body, + 'created_at' => created_at.to_s, + 'updated_at' => updated_at.to_s, + 'end_line' => end_line, + 'start_line' => start_line, + 'in_reply_to_id' => in_reply_to_id + } + end - it 'returns the multi-line suggestion formatted in the note' do - hash = { - 'start_line': 20, - 'end_line': 23, - 'note': <<~BODY - ```suggestion - Hello World - ``` - BODY - } - - note = described_class.new(hash) - - expect(note.note).to eq <<~BODY - ```suggestion:-3+0 - Hello World - ``` - BODY + subject(:note) { described_class.from_json_hash(hash) } end end end diff --git a/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb b/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb index 2ffd5f50d3b..bcb8575bdbf 100644 --- a/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb @@ -9,13 +9,19 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note)).to eq(note) + note_formatter = described_class.new(note: note) + + expect(note_formatter.formatted_note).to eq(note) + expect(note_formatter.contains_suggestion?).to eq(false) end it 'handles nil value for note' do note = nil - expect(described_class.formatted_note_for(note: note)).to eq(note) + note_formatter = described_class.new(note: note) + + expect(note_formatter.formatted_note).to eq(note) + expect(note_formatter.contains_suggestion?).to eq(false) end it 'does not allow over 3 leading spaces for valid suggestion' do @@ -26,7 +32,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note)).to eq(note) + note_formatter = described_class.new(note: note) + + expect(note_formatter.formatted_note).to eq(note) + expect(note_formatter.contains_suggestion?).to eq(false) end it 'allows up to 3 leading spaces' do @@ -44,7 +53,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note)).to eq(expected) + note_formatter = described_class.new(note: note) + + expect(note_formatter.formatted_note).to eq(expected) + expect(note_formatter.contains_suggestion?).to eq(true) end it 'does nothing when there is any text without space after the suggestion tag' do @@ -53,7 +65,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note)).to eq(note) + note_formatter = described_class.new(note: note) + + expect(note_formatter.formatted_note).to eq(note) + expect(note_formatter.contains_suggestion?).to eq(false) end it 'formats single-line suggestions' do @@ -71,7 +86,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note)).to eq(expected) + note_formatter = described_class.new(note: note) + + expect(note_formatter.formatted_note).to eq(expected) + expect(note_formatter.contains_suggestion?).to eq(true) end it 'ignores text after suggestion tag on the same line' do @@ -89,7 +107,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note)).to eq(expected) + note_formatter = described_class.new(note: note) + + expect(note_formatter.formatted_note).to eq(expected) + expect(note_formatter.contains_suggestion?).to eq(true) end it 'formats multiple single-line suggestions' do @@ -115,7 +136,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note)).to eq(expected) + note_formatter = described_class.new(note: note) + + expect(note_formatter.formatted_note).to eq(expected) + expect(note_formatter.contains_suggestion?).to eq(true) end it 'formats multi-line suggestions' do @@ -133,7 +157,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note, start_line: 6, end_line: 8)).to eq(expected) + note_formatter = described_class.new(note: note, start_line: 6, end_line: 8) + + expect(note_formatter.formatted_note).to eq(expected) + expect(note_formatter.contains_suggestion?).to eq(true) end it 'formats multiple multi-line suggestions' do @@ -159,6 +186,9 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ``` BODY - expect(described_class.formatted_note_for(note: note, start_line: 6, end_line: 8)).to eq(expected) + note_formatter = described_class.new(note: note, start_line: 6, end_line: 8) + + expect(note_formatter.formatted_note).to eq(expected) + expect(note_formatter.contains_suggestion?).to eq(true) end end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 55102554508..20d5972bd88 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -136,7 +136,7 @@ RSpec.describe Gitlab::Gpg::Commit do it 'returns a valid signature' do verified_signature = double('verified-signature', fingerprint: GpgHelpers::User1.fingerprint, valid?: true) allow(GPGME::Crypto).to receive(:new).and_return(crypto) - allow(crypto).to receive(:verify).and_return(verified_signature) + allow(crypto).to receive(:verify).and_yield(verified_signature) signature = described_class.new(commit).signature @@ -178,7 +178,7 @@ RSpec.describe Gitlab::Gpg::Commit do keyid = GpgHelpers::User1.fingerprint.last(16) verified_signature = double('verified-signature', fingerprint: keyid, valid?: true) allow(GPGME::Crypto).to receive(:new).and_return(crypto) - allow(crypto).to receive(:verify).and_return(verified_signature) + allow(crypto).to receive(:verify).and_yield(verified_signature) signature = described_class.new(commit).signature @@ -194,6 +194,71 @@ RSpec.describe Gitlab::Gpg::Commit do end end + context 'commit with multiple signatures' do + let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first } + + let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) } + + let!(:gpg_key) do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + let!(:crypto) { instance_double(GPGME::Crypto) } + + before do + fake_signature = [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ] + + allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily) + .with(Gitlab::Git::Repository, commit_sha) + .and_return(fake_signature) + end + + it 'returns an invalid signatures error' do + verified_signature = double('verified-signature', fingerprint: GpgHelpers::User1.fingerprint, valid?: true) + allow(GPGME::Crypto).to receive(:new).and_return(crypto) + allow(crypto).to receive(:verify).and_yield(verified_signature).and_yield(verified_signature) + + signature = described_class.new(commit).signature + + expect(signature).to have_attributes( + commit_sha: commit_sha, + project: project, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: GpgHelpers::User1.names.first, + gpg_key_user_email: GpgHelpers::User1.emails.first, + verification_status: 'multiple_signatures' + ) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(multiple_gpg_signatures: false) + end + + it 'returns an valid signature' do + verified_signature = double('verified-signature', fingerprint: GpgHelpers::User1.fingerprint, valid?: true) + allow(GPGME::Crypto).to receive(:new).and_return(crypto) + allow(crypto).to receive(:verify).and_yield(verified_signature).and_yield(verified_signature) + + signature = described_class.new(commit).signature + + expect(signature).to have_attributes( + commit_sha: commit_sha, + project: project, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: GpgHelpers::User1.names.first, + gpg_key_user_email: GpgHelpers::User1.emails.first, + verification_status: 'verified' + ) + end + end + end + context 'commit signed with a subkey' do let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User3.emails.first } diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb index c1516a48b80..771f6e1ec46 100644 --- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -140,6 +140,8 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do key: GpgHelpers::User1.public_key, user: user + user.reload # necessary to reload the association with gpg_keys + expect(invalid_gpg_signature.reload.verification_status).to eq 'unverified_key' # InvalidGpgSignatureUpdater is called by the after_update hook diff --git a/spec/lib/gitlab/grape_logging/loggers/perf_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/perf_logger_spec.rb index 641fb27a071..ef4bc0ca104 100644 --- a/spec/lib/gitlab/grape_logging/loggers/perf_logger_spec.rb +++ b/spec/lib/gitlab/grape_logging/loggers/perf_logger_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::GrapeLogging::Loggers::PerfLogger do - let(:mock_request) { OpenStruct.new(env: {}) } + let(:mock_request) { double('env', env: {}) } describe ".parameters" do subject { described_class.new.parameters(mock_request, nil) } diff --git a/spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb index 9538c4bae2b..4cd9f9dfad0 100644 --- a/spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb +++ b/spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::GrapeLogging::Loggers::QueueDurationLogger do let(:start_time) { Time.new(2018, 01, 01) } describe 'when no proxy time is available' do - let(:mock_request) { OpenStruct.new(env: {}) } + let(:mock_request) { double('env', env: {}) } it 'returns an empty hash' do expect(subject.parameters(mock_request, nil)).to eq({}) @@ -18,7 +18,7 @@ RSpec.describe Gitlab::GrapeLogging::Loggers::QueueDurationLogger do describe 'when a proxy time is available' do let(:mock_request) do - OpenStruct.new( + double('env', env: { 'HTTP_GITLAB_WORKHORSE_PROXY_START' => (start_time - 1.hour).to_i * (10**9) } diff --git a/spec/lib/gitlab/grape_logging/loggers/urgency_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/urgency_logger_spec.rb new file mode 100644 index 00000000000..464534f0271 --- /dev/null +++ b/spec/lib/gitlab/grape_logging/loggers/urgency_logger_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GrapeLogging::Loggers::UrgencyLogger do + def endpoint(options, namespace: '') + Struct.new(:options, :namespace).new(options, namespace) + end + + let(:api_class) do + Class.new(API::Base) do + namespace 'testing' do + # rubocop:disable Rails/HttpPositionalArguments + # This is not the get that performs a request, but the one from Grape + get 'test', urgency: :high do + {} + end + # rubocop:enable Rails/HttpPositionalArguments + end + end + end + + describe ".parameters" do + where(:request_env, :expected_parameters) do + [ + [{}, {}], + [{ 'api.endpoint' => endpoint({}) }, {}], + [{ 'api.endpoint' => endpoint({ for: 'something weird' }) }, {}], + [ + { 'api.endpoint' => endpoint({ for: api_class, path: [] }) }, + { request_urgency: :default, target_duration_s: 1 } + ], + [ + { 'api.endpoint' => endpoint({ for: api_class, path: ['test'] }, namespace: '/testing') }, + { request_urgency: :high, target_duration_s: 0.25 } + ] + ] + end + + with_them do + let(:request) { double('request', env: request_env) } + + subject { described_class.new.parameters(request, nil) } + + it { is_expected.to eq(expected_parameters) } + end + end +end diff --git a/spec/lib/gitlab/graphql/known_operations_spec.rb b/spec/lib/gitlab/graphql/known_operations_spec.rb new file mode 100644 index 00000000000..411c0876f82 --- /dev/null +++ b/spec/lib/gitlab/graphql/known_operations_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' +require "support/graphql/fake_query_type" + +RSpec.describe Gitlab::Graphql::KnownOperations do + using RSpec::Parameterized::TableSyntax + + # Include duplicated operation names to test that we are unique-ifying them + let(:fake_operations) { %w(foo foo bar bar) } + let(:fake_schema) do + Class.new(GraphQL::Schema) do + query Graphql::FakeQueryType + end + end + + subject { described_class.new(fake_operations) } + + describe "#from_query" do + where(:query_string, :expected) do + "query { helloWorld }" | described_class::ANONYMOUS + "query fuzzyyy { helloWorld }" | described_class::UNKNOWN + "query foo { helloWorld }" | described_class::Operation.new("foo") + end + + with_them do + it "returns known operation name from GraphQL Query" do + query = ::GraphQL::Query.new(fake_schema, query_string) + + expect(subject.from_query(query)).to eq(expected) + end + end + end + + describe "#operations" do + it "returns array of known operations" do + expect(subject.operations.map(&:name)).to match_array(%w(anonymous unknown foo bar)) + end + end + + describe "Operation#to_caller_id" do + where(:query_string, :expected) do + "query { helloWorld }" | "graphql:#{described_class::ANONYMOUS.name}" + "query foo { helloWorld }" | "graphql:foo" + end + + with_them do + it "formats operation name for caller_id metric property" do + query = ::GraphQL::Query.new(fake_schema, query_string) + + expect(subject.from_query(query).to_caller_id).to eq(expected) + end + end + end + + describe "Opeartion#query_urgency" do + it "returns the associated query urgency" do + query = ::GraphQL::Query.new(fake_schema, "query foo { helloWorld }") + + expect(subject.from_query(query).query_urgency).to equal(::Gitlab::EndpointAttributes::DEFAULT_URGENCY) + end + end + + describe ".default" do + it "returns a memoization of values from webpack", :aggregate_failures do + # .default could have been referenced in another spec, so we need to clean it up here + described_class.instance_variable_set(:@default, nil) + + expect(Gitlab::Webpack::GraphqlKnownOperations).to receive(:load).once.and_return(fake_operations) + + 2.times { described_class.default } + + # Uses reference equality to verify memoization + expect(described_class.default).to equal(described_class.default) + expect(described_class.default).to be_a(described_class) + expect(described_class.default.operations.map(&:name)).to include(*fake_operations) + end + end +end diff --git a/spec/lib/gitlab/graphql/pagination/connections_spec.rb b/spec/lib/gitlab/graphql/pagination/connections_spec.rb index f3f59113c81..97389b6250e 100644 --- a/spec/lib/gitlab/graphql/pagination/connections_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/connections_spec.rb @@ -8,7 +8,7 @@ RSpec.describe ::Gitlab::Graphql::Pagination::Connections do before(:all) do ActiveRecord::Schema.define do - create_table :testing_pagination_nodes, force: true do |t| + create_table :_test_testing_pagination_nodes, force: true do |t| t.integer :value, null: false end end @@ -16,13 +16,13 @@ RSpec.describe ::Gitlab::Graphql::Pagination::Connections do after(:all) do ActiveRecord::Schema.define do - drop_table :testing_pagination_nodes, force: true + drop_table :_test_testing_pagination_nodes, force: true end end let_it_be(:node_model) do Class.new(ActiveRecord::Base) do - self.table_name = 'testing_pagination_nodes' + self.table_name = '_test_testing_pagination_nodes' end end diff --git a/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb b/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb index fc723138d88..dee8f9e3c64 100644 --- a/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb +++ b/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb @@ -18,12 +18,6 @@ RSpec.describe Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer do GRAPHQL end - describe 'variables' do - subject { initial_value.fetch(:variables) } - - it { is_expected.to eq('{:body=>"[FILTERED]"}') } - end - describe '#final_value' do let(:monotonic_time_before) { 42 } let(:monotonic_time_after) { 500 } @@ -42,7 +36,14 @@ RSpec.describe Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer do it 'inserts duration in seconds to memo and sets request store' do expect { final_value }.to change { memo[:duration_s] }.to(monotonic_time_duration) - .and change { RequestStore.store[:graphql_logs] }.to([memo]) + .and change { RequestStore.store[:graphql_logs] }.to([{ + complexity: 4, + depth: 2, + operation_name: query.operation_name, + used_deprecated_fields: [], + used_fields: [], + variables: { body: "[FILTERED]" }.to_s + }]) end end end diff --git a/spec/lib/gitlab/graphql/tracers/application_context_tracer_spec.rb b/spec/lib/gitlab/graphql/tracers/application_context_tracer_spec.rb new file mode 100644 index 00000000000..6eff816b95a --- /dev/null +++ b/spec/lib/gitlab/graphql/tracers/application_context_tracer_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require "fast_spec_helper" +require "support/graphql/fake_tracer" +require "support/graphql/fake_query_type" + +RSpec.describe Gitlab::Graphql::Tracers::ApplicationContextTracer do + let(:tracer_spy) { spy('tracer_spy') } + let(:default_known_operations) { ::Gitlab::Graphql::KnownOperations.new(['fooOperation']) } + let(:dummy_schema) do + schema = Class.new(GraphQL::Schema) do + use Gitlab::Graphql::Tracers::ApplicationContextTracer + + query Graphql::FakeQueryType + end + + fake_tracer = Graphql::FakeTracer.new(lambda do |key, *args| + tracer_spy.trace(key, Gitlab::ApplicationContext.current) + end) + + schema.tracer(fake_tracer) + + schema + end + + before do + allow(::Gitlab::Graphql::KnownOperations).to receive(:default).and_return(default_known_operations) + end + + it "sets application context during execute_query and cleans up afterwards", :aggregate_failures do + dummy_schema.execute("query fooOperation { helloWorld }") + + # "parse" is just an arbitrary trace event that isn't setting caller_id + expect(tracer_spy).to have_received(:trace).with("parse", hash_excluding("meta.caller_id")) + expect(tracer_spy).to have_received(:trace).with("execute_query", hash_including("meta.caller_id" => "graphql:fooOperation")).once + expect(Gitlab::ApplicationContext.current).not_to include("meta.caller_id") + end + + it "sets caller_id when operation is not known" do + dummy_schema.execute("query fuzz { helloWorld }") + + expect(tracer_spy).to have_received(:trace).with("execute_query", hash_including("meta.caller_id" => "graphql:unknown")).once + end +end diff --git a/spec/lib/gitlab/graphql/tracers/logger_tracer_spec.rb b/spec/lib/gitlab/graphql/tracers/logger_tracer_spec.rb new file mode 100644 index 00000000000..d83ac4dabc5 --- /dev/null +++ b/spec/lib/gitlab/graphql/tracers/logger_tracer_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +require "fast_spec_helper" +require "support/graphql/fake_query_type" + +RSpec.describe Gitlab::Graphql::Tracers::LoggerTracer do + let(:dummy_schema) do + Class.new(GraphQL::Schema) do + # LoggerTracer depends on TimerTracer + use Gitlab::Graphql::Tracers::LoggerTracer + use Gitlab::Graphql::Tracers::TimerTracer + + query_analyzer Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer.new + + query Graphql::FakeQueryType + end + end + + around do |example| + Gitlab::ApplicationContext.with_context(caller_id: 'caller_a', feature_category: 'feature_a') do + example.run + end + end + + it "logs every query", :aggregate_failures do + variables = { name: "Ada Lovelace" } + query_string = 'query fooOperation($name: String) { helloWorld(message: $name) }' + + # Build an actual query so we don't have to hardocde the "fingerprint" calculations + query = GraphQL::Query.new(dummy_schema, query_string, variables: variables) + + expect(::Gitlab::GraphqlLogger).to receive(:info).with({ + "correlation_id" => anything, + "meta.caller_id" => "caller_a", + "meta.feature_category" => "feature_a", + "query_analysis.duration_s" => kind_of(Numeric), + "query_analysis.complexity" => 1, + "query_analysis.depth" => 1, + "query_analysis.used_deprecated_fields" => [], + "query_analysis.used_fields" => ["FakeQuery.helloWorld"], + duration_s: be > 0, + is_mutation: false, + operation_fingerprint: query.operation_fingerprint, + operation_name: 'fooOperation', + query_fingerprint: query.fingerprint, + query_string: query_string, + trace_type: "execute_query", + variables: variables.to_s + }) + + dummy_schema.execute(query_string, variables: variables) + end +end diff --git a/spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb b/spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb new file mode 100644 index 00000000000..ff6a76aa319 --- /dev/null +++ b/spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' +require "support/graphql/fake_query_type" + +RSpec.describe Gitlab::Graphql::Tracers::MetricsTracer do + using RSpec::Parameterized::TableSyntax + + let(:default_known_operations) { ::Gitlab::Graphql::KnownOperations.new(%w(lorem foo bar)) } + + let(:fake_schema) do + Class.new(GraphQL::Schema) do + use Gitlab::Graphql::Tracers::ApplicationContextTracer + use Gitlab::Graphql::Tracers::MetricsTracer + use Gitlab::Graphql::Tracers::TimerTracer + + query Graphql::FakeQueryType + end + end + + around do |example| + ::Gitlab::ApplicationContext.with_context(feature_category: 'test_feature_category') do + example.run + end + end + + before do + allow(::Gitlab::Graphql::KnownOperations).to receive(:default).and_return(default_known_operations) + end + + describe 'when used as tracer and query is executed' do + where(:duration, :expected_success) do + 0.1 | true + 0.1 + ::Gitlab::EndpointAttributes::DEFAULT_URGENCY.duration | false + end + + with_them do + it 'increments sli' do + # Trigger initialization + fake_schema + + # setup timer + current_time = 0 + allow(Gitlab::Metrics::System).to receive(:monotonic_time) { current_time += duration } + + expect(Gitlab::Metrics::RailsSlis.graphql_query_apdex).to receive(:increment).with( + labels: { + endpoint_id: 'graphql:lorem', + feature_category: 'test_feature_category', + query_urgency: ::Gitlab::EndpointAttributes::DEFAULT_URGENCY.name + }, + success: expected_success + ) + + fake_schema.execute("query lorem { helloWorld }") + end + end + end +end diff --git a/spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb b/spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb new file mode 100644 index 00000000000..7f837e28772 --- /dev/null +++ b/spec/lib/gitlab/graphql/tracers/timer_tracer_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +require "fast_spec_helper" +require "support/graphql/fake_tracer" +require "support/graphql/fake_query_type" + +RSpec.describe Gitlab::Graphql::Tracers::TimerTracer do + let(:expected_duration) { 5 } + let(:tracer_spy) { spy('tracer_spy') } + let(:dummy_schema) do + schema = Class.new(GraphQL::Schema) do + use Gitlab::Graphql::Tracers::TimerTracer + + query Graphql::FakeQueryType + end + + schema.tracer(Graphql::FakeTracer.new(lambda { |*args| tracer_spy.trace(*args) })) + + schema + end + + before do + current_time = 0 + allow(Gitlab::Metrics::System).to receive(:monotonic_time) do + current_time += expected_duration + end + end + + it "adds duration_s to the trace metadata", :aggregate_failures do + query_string = "query fooOperation { helloWorld }" + + dummy_schema.execute(query_string) + + # "parse" and "execute_query" are just arbitrary trace events + expect(tracer_spy).to have_received(:trace).with("parse", { + duration_s: expected_duration, + query_string: query_string + }) + expect(tracer_spy).to have_received(:trace).with("execute_query", { + # greater than expected duration because other calls made to `.monotonic_time` are outside our control + duration_s: be >= expected_duration, + query: instance_of(GraphQL::Query) + }) + end +end diff --git a/spec/lib/gitlab/health_checks/redis/redis_check_spec.rb b/spec/lib/gitlab/health_checks/redis/redis_check_spec.rb index 43e890a6c4f..145d573b6de 100644 --- a/spec/lib/gitlab/health_checks/redis/redis_check_spec.rb +++ b/spec/lib/gitlab/health_checks/redis/redis_check_spec.rb @@ -4,5 +4,5 @@ require 'spec_helper' require_relative '../simple_check_shared' RSpec.describe Gitlab::HealthChecks::Redis::RedisCheck do - include_examples 'simple_check', 'redis_ping', 'Redis', 'PONG' + include_examples 'simple_check', 'redis_ping', 'Redis', true end diff --git a/spec/lib/gitlab/import/database_helpers_spec.rb b/spec/lib/gitlab/import/database_helpers_spec.rb index 079faed2518..05d1c0ae078 100644 --- a/spec/lib/gitlab/import/database_helpers_spec.rb +++ b/spec/lib/gitlab/import/database_helpers_spec.rb @@ -16,8 +16,8 @@ RSpec.describe Gitlab::Import::DatabaseHelpers do let(:project) { create(:project) } it 'returns the ID returned by the query' do - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .with(Issue.table_name, [attributes], return_ids: true) .and_return([10]) diff --git a/spec/lib/gitlab/import/metrics_spec.rb b/spec/lib/gitlab/import/metrics_spec.rb index 035294a620f..9b8b58d00f3 100644 --- a/spec/lib/gitlab/import/metrics_spec.rb +++ b/spec/lib/gitlab/import/metrics_spec.rb @@ -94,20 +94,6 @@ RSpec.describe Gitlab::Import::Metrics, :aggregate_failures do expect(histogram).to have_received(:observe).with({ importer: :test_importer }, anything) end end - - context 'when project is a github import' do - before do - project.import_type = 'github' - end - - it 'emits importer metrics' do - expect(subject).to receive(:track_usage_event).with(:github_import_project_success, project.id) - - subject.track_finished_import - - expect(histogram).to have_received(:observe).with({ project: project.full_path }, anything) - end - end end describe '#issues_counter' do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 10f0e687077..b474f5825fd 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -60,6 +60,7 @@ issues: - incident_management_issuable_escalation_status - pending_escalations - customer_relations_contacts +- issue_customer_relations_contacts work_item_type: - issues events: @@ -132,6 +133,7 @@ project_members: - user - source - project +- member_task merge_requests: - status_check_responses - subscriptions @@ -382,6 +384,7 @@ project: - emails_on_push_integration - pipelines_email_integration - mattermost_slash_commands_integration +- shimo_integration - slack_slash_commands_integration - irker_integration - packagist_integration diff --git a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb index 2b974f8985d..8ae387d95e3 100644 --- a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb +++ b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb @@ -80,25 +80,66 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do let(:attributes_permitter) { described_class.new } - where(:relation_name, :permitted_attributes_defined) do - :user | false - :author | false - :ci_cd_settings | true - :metrics_setting | true - :project_badges | true - :pipeline_schedules | true - :error_tracking_setting | true - :auto_devops | true - :boards | true - :custom_attributes | true - :labels | true - :protected_branches | true - :protected_tags | true - :create_access_levels | true - :merge_access_levels | true - :push_access_levels | true - :releases | true - :links | true + where(:relation_name, :permitted_attributes_defined ) do + :user | true + :author | false + :ci_cd_settings | true + :metrics_setting | true + :project_badges | true + :pipeline_schedules | true + :error_tracking_setting | true + :auto_devops | true + :boards | true + :custom_attributes | true + :label | true + :labels | true + :protected_branches | true + :protected_tags | true + :create_access_levels | true + :merge_access_levels | true + :push_access_levels | true + :releases | true + :links | true + :priorities | true + :milestone | true + :milestones | true + :snippets | true + :project_members | true + :merge_request | true + :merge_requests | true + :award_emoji | true + :commit_author | true + :committer | true + :events | true + :label_links | true + :merge_request_diff | true + :merge_request_diff_commits | true + :merge_request_diff_files | true + :metrics | true + :notes | true + :push_event_payload | true + :resource_label_events | true + :suggestions | true + :system_note_metadata | true + :timelogs | true + :container_expiration_policy | true + :project_feature | true + :prometheus_metrics | true + :service_desk_setting | true + :external_pull_request | true + :external_pull_requests | true + :statuses | true + :ci_pipelines | true + :stages | true + :actions | true + :design | true + :designs | true + :design_versions | true + :issue_assignees | true + :sentry_issue | true + :zoom_meetings | true + :issues | true + :group_members | true end with_them do @@ -109,9 +150,11 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do describe 'included_attributes for Project' do subject { described_class.new } + additional_attributes = { user: %w[id] } + Gitlab::ImportExport::Config.new.to_h[:included_attributes].each do |relation_sym, permitted_attributes| context "for #{relation_sym}" do - it_behaves_like 'a permitted attribute', relation_sym, permitted_attributes + it_behaves_like 'a permitted attribute', relation_sym, permitted_attributes, additional_attributes[relation_sym] end end end diff --git a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb index fc08a13a8bd..d5f31f235f5 100644 --- a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb @@ -207,9 +207,9 @@ RSpec.describe Gitlab::ImportExport::FastHashSerializer do context 'relation ordering' do it 'orders exported pipelines by primary key' do - expected_order = project.ci_pipelines.reorder(:id).ids + expected_order = project.ci_pipelines.reorder(:id).pluck(:sha) - expect(subject['ci_pipelines'].pluck('id')).to eq(expected_order) + expect(subject['ci_pipelines'].pluck('sha')).to eq(expected_order) end end diff --git a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb new file mode 100644 index 00000000000..473dbf5ecc5 --- /dev/null +++ b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# This spec is a lightweight version of: +# * project/tree_restorer_spec.rb +# +# In depth testing is being done in the above specs. +# This spec tests that restore project works +# but does not have 100% relation coverage. + +require 'spec_helper' + +RSpec.describe Gitlab::ImportExport::Group::RelationTreeRestorer do + let_it_be(:group) { create(:group) } + let_it_be(:importable) { create(:group, parent: group) } + + include_context 'relation tree restorer shared context' do + let(:importable_name) { nil } + end + + let(:path) { 'spec/fixtures/lib/gitlab/import_export/group_exports/no_children/group.json' } + let(:relation_reader) do + Gitlab::ImportExport::Json::LegacyReader::File.new( + path, + relation_names: reader.group_relation_names) + end + + let(:reader) do + Gitlab::ImportExport::Reader.new( + shared: shared, + config: Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.legacy_group_config_file).to_h + ) + end + + let(:relation_tree_restorer) do + described_class.new( + user: user, + shared: shared, + relation_reader: relation_reader, + object_builder: Gitlab::ImportExport::Group::ObjectBuilder, + members_mapper: members_mapper, + relation_factory: Gitlab::ImportExport::Group::RelationFactory, + reader: reader, + importable: importable, + importable_path: nil, + importable_attributes: attributes + ) + end + + subject { relation_tree_restorer.restore } + + shared_examples 'logging of relations creation' do + context 'when log_import_export_relation_creation feature flag is enabled' do + before do + stub_feature_flags(log_import_export_relation_creation: group) + end + + it 'logs top-level relation creation' do + expect(shared.logger) + .to receive(:info) + .with(hash_including(message: '[Project/Group Import] Created new object relation')) + .at_least(:once) + + subject + end + end + + context 'when log_import_export_relation_creation feature flag is disabled' do + before do + stub_feature_flags(log_import_export_relation_creation: false) + end + + it 'does not log top-level relation creation' do + expect(shared.logger) + .to receive(:info) + .with(hash_including(message: '[Project/Group Import] Created new object relation')) + .never + + subject + end + end + end + + it 'restores group tree' do + expect(subject).to eq(true) + end + + include_examples 'logging of relations creation' +end diff --git a/spec/lib/gitlab/import_export/project/object_builder_spec.rb b/spec/lib/gitlab/import_export/project/object_builder_spec.rb index 4c9f9f7c690..189b798c2e8 100644 --- a/spec/lib/gitlab/import_export/project/object_builder_spec.rb +++ b/spec/lib/gitlab/import_export/project/object_builder_spec.rb @@ -123,6 +123,24 @@ RSpec.describe Gitlab::ImportExport::Project::ObjectBuilder do expect(milestone.persisted?).to be true end + + context 'with clashing iid' do + it 'creates milestone and claims iid for the new milestone' do + clashing_iid = 1 + create(:milestone, iid: clashing_iid, project: project) + + milestone = described_class.build(Milestone, + 'iid' => clashing_iid, + 'title' => 'milestone', + 'project' => project, + 'group' => nil, + 'group_id' => nil) + + expect(milestone.persisted?).to be true + expect(Milestone.count).to eq(2) + expect(milestone.iid).to eq(clashing_iid) + end + end end context 'merge_request' do @@ -176,4 +194,118 @@ RSpec.describe Gitlab::ImportExport::Project::ObjectBuilder do expect(found.email).to eq('alice@example.com') end end + + context 'merge request diff commits' do + context 'when the "committer" object is present' do + it 'uses this object as the committer' do + user = MergeRequest::DiffCommitUser + .find_or_create('Alice', 'alice@example.com') + + commit = described_class.build( + MergeRequestDiffCommit, + { + 'committer' => user, + 'committer_name' => 'Bla', + 'committer_email' => 'bla@example.com', + 'author_name' => 'Bla', + 'author_email' => 'bla@example.com' + } + ) + + expect(commit.committer).to eq(user) + end + end + + context 'when the "committer" object is missing' do + it 'creates one from the committer name and Email' do + commit = described_class.build( + MergeRequestDiffCommit, + { + 'committer_name' => 'Alice', + 'committer_email' => 'alice@example.com', + 'author_name' => 'Alice', + 'author_email' => 'alice@example.com' + } + ) + + expect(commit.committer.name).to eq('Alice') + expect(commit.committer.email).to eq('alice@example.com') + end + end + + context 'when the "commit_author" object is present' do + it 'uses this object as the author' do + user = MergeRequest::DiffCommitUser + .find_or_create('Alice', 'alice@example.com') + + commit = described_class.build( + MergeRequestDiffCommit, + { + 'committer_name' => 'Alice', + 'committer_email' => 'alice@example.com', + 'commit_author' => user, + 'author_name' => 'Bla', + 'author_email' => 'bla@example.com' + } + ) + + expect(commit.commit_author).to eq(user) + end + end + + context 'when the "commit_author" object is missing' do + it 'creates one from the author name and Email' do + commit = described_class.build( + MergeRequestDiffCommit, + { + 'committer_name' => 'Alice', + 'committer_email' => 'alice@example.com', + 'author_name' => 'Alice', + 'author_email' => 'alice@example.com' + } + ) + + expect(commit.commit_author.name).to eq('Alice') + expect(commit.commit_author.email).to eq('alice@example.com') + end + end + end + + describe '#find_or_create_diff_commit_user' do + context 'when the user already exists' do + it 'returns the existing user' do + user = MergeRequest::DiffCommitUser + .find_or_create('Alice', 'alice@example.com') + + found = described_class + .new(MergeRequestDiffCommit, {}) + .send(:find_or_create_diff_commit_user, user.name, user.email) + + expect(found).to eq(user) + end + end + + context 'when the user does not exist' do + it 'creates the user' do + found = described_class + .new(MergeRequestDiffCommit, {}) + .send(:find_or_create_diff_commit_user, 'Alice', 'alice@example.com') + + expect(found.name).to eq('Alice') + expect(found.email).to eq('alice@example.com') + end + end + + it 'caches the results' do + builder = described_class.new(MergeRequestDiffCommit, {}) + + builder.send(:find_or_create_diff_commit_user, 'Alice', 'alice@example.com') + + record = ActiveRecord::QueryRecorder.new do + builder.send(:find_or_create_diff_commit_user, 'Alice', 'alice@example.com') + end + + expect(record.count).to eq(1) + end + end end diff --git a/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb new file mode 100644 index 00000000000..5ebace263ba --- /dev/null +++ b/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +# This spec is a lightweight version of: +# * project/tree_restorer_spec.rb +# +# In depth testing is being done in the above specs. +# This spec tests that restore project works +# but does not have 100% relation coverage. + +require 'spec_helper' + +RSpec.describe Gitlab::ImportExport::Project::RelationTreeRestorer do + let_it_be(:importable, reload: true) do + create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') + end + + include_context 'relation tree restorer shared context' do + let(:importable_name) { 'project' } + end + + let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) } + let(:relation_tree_restorer) do + described_class.new( + user: user, + shared: shared, + relation_reader: relation_reader, + object_builder: Gitlab::ImportExport::Project::ObjectBuilder, + members_mapper: members_mapper, + relation_factory: Gitlab::ImportExport::Project::RelationFactory, + reader: reader, + importable: importable, + importable_path: 'project', + importable_attributes: attributes + ) + end + + subject { relation_tree_restorer.restore } + + shared_examples 'import project successfully' do + describe 'imported project' do + it 'has the project attributes and relations', :aggregate_failures do + expect(subject).to eq(true) + + project = Project.find_by_path('project') + + expect(project.description).to eq('Nisi et repellendus ut enim quo accusamus vel magnam.') + expect(project.labels.count).to eq(3) + expect(project.boards.count).to eq(1) + expect(project.project_feature).not_to be_nil + expect(project.custom_attributes.count).to eq(2) + expect(project.project_badges.count).to eq(2) + expect(project.snippets.count).to eq(1) + end + end + end + + shared_examples 'logging of relations creation' do + context 'when log_import_export_relation_creation feature flag is enabled' do + before do + stub_feature_flags(log_import_export_relation_creation: group) + end + + it 'logs top-level relation creation' do + expect(shared.logger) + .to receive(:info) + .with(hash_including(message: '[Project/Group Import] Created new object relation')) + .at_least(:once) + + subject + end + end + + context 'when log_import_export_relation_creation feature flag is disabled' do + before do + stub_feature_flags(log_import_export_relation_creation: false) + end + + it 'does not log top-level relation creation' do + expect(shared.logger) + .to receive(:info) + .with(hash_including(message: '[Project/Group Import] Created new object relation')) + .never + + subject + end + end + end + + context 'with legacy reader' do + let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/project.json' } + let(:relation_reader) do + Gitlab::ImportExport::Json::LegacyReader::File.new( + path, + relation_names: reader.project_relation_names, + allowed_path: 'project' + ) + end + + let(:attributes) { relation_reader.consume_attributes('project') } + + it_behaves_like 'import project successfully' + + context 'with logging of relations creation' do + let_it_be(:group) { create(:group) } + let_it_be(:importable) do + create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project', group: group) + end + + include_examples 'logging of relations creation' + end + end + + context 'with ndjson reader' do + let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/tree' } + let(:relation_reader) { Gitlab::ImportExport::Json::NdjsonReader.new(path) } + + it_behaves_like 'import project successfully' + + context 'when inside a group' do + let_it_be(:group) do + create(:group, :disabled_and_unoverridable) + end + + before do + importable.update!(shared_runners_enabled: false, group: group) + end + + it_behaves_like 'import project successfully' + end + end + + context 'with invalid relations' do + let(:path) { 'spec/fixtures/lib/gitlab/import_export/project_with_invalid_relations/tree' } + let(:relation_reader) { Gitlab::ImportExport::Json::NdjsonReader.new(path) } + + it 'logs the invalid relation and its errors' do + expect(shared.logger) + .to receive(:warn) + .with( + error_messages: "Title can't be blank. Title is invalid", + message: '[Project/Group Import] Invalid object relation built', + relation_class: 'ProjectLabel', + relation_index: 0, + relation_key: 'labels' + ).once + + relation_tree_restorer.restore + end + end +end diff --git a/spec/lib/gitlab/import_export/project/sample/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/sample/relation_tree_restorer_spec.rb index f6a028383f2..3dab84af744 100644 --- a/spec/lib/gitlab/import_export/project/sample/relation_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project/sample/relation_tree_restorer_spec.rb @@ -10,19 +10,26 @@ require 'spec_helper' RSpec.describe Gitlab::ImportExport::Project::Sample::RelationTreeRestorer do - include_context 'relation tree restorer shared context' + let_it_be(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') } + include_context 'relation tree restorer shared context' do + let(:importable_name) { 'project' } + end + + let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) } + let(:path) { 'spec/fixtures/lib/gitlab/import_export/sample_data/tree' } + let(:relation_reader) { Gitlab::ImportExport::Json::NdjsonReader.new(path) } let(:sample_data_relation_tree_restorer) do described_class.new( user: user, shared: shared, relation_reader: relation_reader, - object_builder: object_builder, + object_builder: Gitlab::ImportExport::Project::ObjectBuilder, members_mapper: members_mapper, - relation_factory: relation_factory, + relation_factory: Gitlab::ImportExport::Project::Sample::RelationFactory, reader: reader, importable: importable, - importable_path: importable_path, + importable_path: 'project', importable_attributes: attributes ) end @@ -69,32 +76,21 @@ RSpec.describe Gitlab::ImportExport::Project::Sample::RelationTreeRestorer do end end - context 'when restoring a project' do - let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') } - let(:importable_name) { 'project' } - let(:importable_path) { 'project' } - let(:object_builder) { Gitlab::ImportExport::Project::ObjectBuilder } - let(:relation_factory) { Gitlab::ImportExport::Project::Sample::RelationFactory } - let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) } - let(:path) { 'spec/fixtures/lib/gitlab/import_export/sample_data/tree' } - let(:relation_reader) { Gitlab::ImportExport::Json::NdjsonReader.new(path) } - - it 'initializes relation_factory with date_calculator as parameter' do - expect(Gitlab::ImportExport::Project::Sample::RelationFactory).to receive(:create).with(hash_including(:date_calculator)).at_least(:once).times + it 'initializes relation_factory with date_calculator as parameter' do + expect(Gitlab::ImportExport::Project::Sample::RelationFactory).to receive(:create).with(hash_including(:date_calculator)).at_least(:once).times - subject - end + subject + end - context 'when relation tree restorer is initialized' do - it 'initializes date calculator with due dates' do - expect(Gitlab::ImportExport::Project::Sample::DateCalculator).to receive(:new).with(Array) + context 'when relation tree restorer is initialized' do + it 'initializes date calculator with due dates' do + expect(Gitlab::ImportExport::Project::Sample::DateCalculator).to receive(:new).with(Array) - sample_data_relation_tree_restorer - end + sample_data_relation_tree_restorer end + end - context 'using ndjson reader' do - it_behaves_like 'import project successfully' - end + context 'using ndjson reader' do + it_behaves_like 'import project successfully' end end diff --git a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb index f512f49764d..cd3d29f1a51 100644 --- a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb @@ -23,7 +23,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer do ] RSpec::Mocks.with_temporary_scope do - @project = create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') + @project = create(:project, :repository, :builds_enabled, :issues_disabled, name: 'project', path: 'project') @shared = @project.import_export_shared stub_all_feature_flags @@ -36,7 +36,6 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer do allow_any_instance_of(Gitlab::Git::Repository).to receive(:branch_exists?).and_return(false) expect(@shared).not_to receive(:error) - expect_any_instance_of(Gitlab::Git::Repository).to receive(:create_branch).with('feature', 'DCBA') allow_any_instance_of(Gitlab::Git::Repository).to receive(:create_branch) project_tree_restorer = described_class.new(user: @user, shared: @shared, project: @project) diff --git a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb index 374d688576e..f68ec21039d 100644 --- a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb @@ -5,6 +5,9 @@ require 'spec_helper' RSpec.describe Gitlab::ImportExport::Project::TreeSaver do let_it_be(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let_it_be(:exportable_path) { 'project' } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { setup_project } shared_examples 'saves project tree successfully' do |ndjson_enabled| include ImportExport::CommonUtil @@ -12,9 +15,6 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver do subject { get_json(full_path, exportable_path, relation_name, ndjson_enabled) } describe 'saves project tree attributes' do - let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:project) { setup_project } let_it_be(:shared) { project.import_export_shared } let(:relation_name) { :projects } @@ -402,6 +402,50 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver do it_behaves_like "saves project tree successfully", true end + context 'when streaming has to retry', :aggregate_failures do + let(:shared) { double('shared', export_path: exportable_path) } + let(:logger) { Gitlab::Import::Logger.build } + let(:serializer) { double('serializer') } + let(:error_class) { Net::OpenTimeout } + let(:info_params) do + { + 'error.class': error_class, + project_name: project.name, + project_id: project.id + } + end + + before do + allow(Gitlab::ImportExport::Json::StreamingSerializer).to receive(:new).and_return(serializer) + end + + subject(:project_tree_saver) do + described_class.new(project: project, current_user: user, shared: shared, logger: logger) + end + + it 'retries and succeeds' do + call_count = 0 + allow(serializer).to receive(:execute) do + call_count += 1 + call_count > 1 ? true : raise(error_class, 'execution expired') + end + + expect(logger).to receive(:info).with(hash_including(info_params)).once + + expect(project_tree_saver.save).to be(true) + end + + it 'retries and does not succeed' do + retry_count = 3 + allow(serializer).to receive(:execute).and_raise(error_class, 'execution expired') + + expect(logger).to receive(:info).with(hash_including(info_params)).exactly(retry_count).times + expect(shared).to receive(:error).with(instance_of(error_class)) + + expect(project_tree_saver.save).to be(false) + end + end + def setup_project release = create(:release) diff --git a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb deleted file mode 100644 index 5e4075c2b59..00000000000 --- a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb +++ /dev/null @@ -1,184 +0,0 @@ -# frozen_string_literal: true - -# This spec is a lightweight version of: -# * project/tree_restorer_spec.rb -# -# In depth testing is being done in the above specs. -# This spec tests that restore project works -# but does not have 100% relation coverage. - -require 'spec_helper' - -RSpec.describe Gitlab::ImportExport::RelationTreeRestorer do - include_context 'relation tree restorer shared context' - - let(:relation_tree_restorer) do - described_class.new( - user: user, - shared: shared, - relation_reader: relation_reader, - object_builder: object_builder, - members_mapper: members_mapper, - relation_factory: relation_factory, - reader: reader, - importable: importable, - importable_path: importable_path, - importable_attributes: attributes - ) - end - - subject { relation_tree_restorer.restore } - - shared_examples 'import project successfully' do - describe 'imported project' do - it 'has the project attributes and relations', :aggregate_failures do - expect(subject).to eq(true) - - project = Project.find_by_path('project') - - expect(project.description).to eq('Nisi et repellendus ut enim quo accusamus vel magnam.') - expect(project.labels.count).to eq(3) - expect(project.boards.count).to eq(1) - expect(project.project_feature).not_to be_nil - expect(project.custom_attributes.count).to eq(2) - expect(project.project_badges.count).to eq(2) - expect(project.snippets.count).to eq(1) - end - end - end - - shared_examples 'logging of relations creation' do - context 'when log_import_export_relation_creation feature flag is enabled' do - before do - stub_feature_flags(log_import_export_relation_creation: group) - end - - it 'logs top-level relation creation' do - expect(relation_tree_restorer.shared.logger) - .to receive(:info) - .with(hash_including(message: '[Project/Group Import] Created new object relation')) - .at_least(:once) - - subject - end - end - - context 'when log_import_export_relation_creation feature flag is disabled' do - before do - stub_feature_flags(log_import_export_relation_creation: false) - end - - it 'does not log top-level relation creation' do - expect(relation_tree_restorer.shared.logger) - .to receive(:info) - .with(hash_including(message: '[Project/Group Import] Created new object relation')) - .never - - subject - end - end - end - - context 'when restoring a project' do - let_it_be(:importable, reload: true) do - create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') - end - - let(:importable_name) { 'project' } - let(:importable_path) { 'project' } - let(:object_builder) { Gitlab::ImportExport::Project::ObjectBuilder } - let(:relation_factory) { Gitlab::ImportExport::Project::RelationFactory } - let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) } - - context 'using legacy reader' do - let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/project.json' } - let(:relation_reader) do - Gitlab::ImportExport::Json::LegacyReader::File.new( - path, - relation_names: reader.project_relation_names, - allowed_path: 'project' - ) - end - - let(:attributes) { relation_reader.consume_attributes('project') } - - it_behaves_like 'import project successfully' - - context 'logging of relations creation' do - let_it_be(:group) { create(:group) } - let_it_be(:importable) do - create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project', group: group) - end - - include_examples 'logging of relations creation' - end - end - - context 'using ndjson reader' do - let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/tree' } - let(:relation_reader) { Gitlab::ImportExport::Json::NdjsonReader.new(path) } - - it_behaves_like 'import project successfully' - - context 'when inside a group' do - let_it_be(:group) do - create(:group, :disabled_and_unoverridable) - end - - before do - importable.update!(shared_runners_enabled: false, group: group) - end - - it_behaves_like 'import project successfully' - end - end - - context 'with invalid relations' do - let(:path) { 'spec/fixtures/lib/gitlab/import_export/project_with_invalid_relations/tree' } - let(:relation_reader) { Gitlab::ImportExport::Json::NdjsonReader.new(path) } - - it 'logs the invalid relation and its errors' do - expect(relation_tree_restorer.shared.logger) - .to receive(:warn) - .with( - error_messages: "Title can't be blank. Title is invalid", - message: '[Project/Group Import] Invalid object relation built', - relation_class: 'ProjectLabel', - relation_index: 0, - relation_key: 'labels' - ).once - - relation_tree_restorer.restore - end - end - end - - context 'when restoring a group' do - let_it_be(:group) { create(:group) } - let_it_be(:importable) { create(:group, parent: group) } - - let(:path) { 'spec/fixtures/lib/gitlab/import_export/group_exports/no_children/group.json' } - let(:importable_name) { nil } - let(:importable_path) { nil } - let(:object_builder) { Gitlab::ImportExport::Group::ObjectBuilder } - let(:relation_factory) { Gitlab::ImportExport::Group::RelationFactory } - let(:relation_reader) do - Gitlab::ImportExport::Json::LegacyReader::File.new( - path, - relation_names: reader.group_relation_names) - end - - let(:reader) do - Gitlab::ImportExport::Reader.new( - shared: shared, - config: Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.legacy_group_config_file).to_h - ) - end - - it 'restores group tree' do - expect(subject).to eq(true) - end - - include_examples 'logging of relations creation' - end -end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 4b125cab49b..9daa3b32fd1 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -561,6 +561,7 @@ Project: - require_password_to_approve - autoclose_referenced_issues - suggestion_commit_message +- merge_commit_template ProjectTracingSetting: - external_url Author: @@ -692,6 +693,7 @@ ProjectCiCdSetting: ProjectSetting: - allow_merge_on_skipped_pipeline - has_confluence +- has_shimo - has_vulnerabilities ProtectedEnvironment: - id diff --git a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb index b2a11353d0c..09280402e2b 100644 --- a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb +++ b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb @@ -111,45 +111,4 @@ RSpec.describe Gitlab::Instrumentation::RedisInterceptor, :clean_gitlab_redis_sh end end end - - context 'when a command takes longer than DURATION_ERROR_THRESHOLD' do - let(:threshold) { 0.5 } - - before do - stub_const("#{described_class}::DURATION_ERROR_THRESHOLD", threshold) - end - - context 'when report_on_long_redis_durations is disabled' do - it 'does nothing' do - stub_feature_flags(report_on_long_redis_durations: false) - - expect(Gitlab::ErrorTracking).not_to receive(:track_exception) - - Gitlab::Redis::SharedState.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } } - end - end - - context 'when report_on_long_redis_durations is enabled' do - context 'for an instance other than SharedState' do - it 'does nothing' do - expect(Gitlab::ErrorTracking).not_to receive(:track_exception) - - Gitlab::Redis::Queues.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } } - end - end - - context 'for the SharedState instance' do - it 'tracks an exception and continues' do - expect(Gitlab::ErrorTracking) - .to receive(:track_exception) - .with(an_instance_of(described_class::MysteryRedisDurationError), - command: 'mget', - duration: be > threshold, - timestamp: a_string_matching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{5}/)) - - Gitlab::Redis::SharedState.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } } - end - end - end - end end diff --git a/spec/lib/gitlab/instrumentation_helper_spec.rb b/spec/lib/gitlab/instrumentation_helper_spec.rb index 52d3623c304..a9663012e9a 100644 --- a/spec/lib/gitlab/instrumentation_helper_spec.rb +++ b/spec/lib/gitlab/instrumentation_helper_spec.rb @@ -147,6 +147,25 @@ RSpec.describe Gitlab::InstrumentationHelper do expect(payload).not_to include(:caught_up_replica_pick_fail) end end + + context 'when there is an uploaded file' do + it 'adds upload data' do + uploaded_file = UploadedFile.from_params({ + 'name' => 'dir/foo.txt', + 'sha256' => 'sha256', + 'remote_url' => 'http://localhost/file', + 'remote_id' => '1234567890', + 'etag' => 'etag1234567890', + 'upload_duration' => '5.05', + 'size' => '123456' + }, nil) + + subject + + expect(payload[:uploaded_file_upload_duration_s]).to eq(uploaded_file.upload_duration) + expect(payload[:uploaded_file_size_bytes]).to eq(uploaded_file.size) + end + end end describe 'duration calculations' do diff --git a/spec/lib/gitlab/issues/rebalancing/state_spec.rb b/spec/lib/gitlab/issues/rebalancing/state_spec.rb index bdd0dbd365d..a849330ad35 100644 --- a/spec/lib/gitlab/issues/rebalancing/state_spec.rb +++ b/spec/lib/gitlab/issues/rebalancing/state_spec.rb @@ -94,7 +94,7 @@ RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_st context 'when tracking new rebalance' do it 'returns as expired for non existent key' do ::Gitlab::Redis::SharedState.with do |redis| - expect(redis.ttl(rebalance_caching.send(:concurrent_running_rebalances_key))).to be < 0 + expect(redis.ttl(Gitlab::Issues::Rebalancing::State::CONCURRENT_RUNNING_REBALANCES_KEY)).to be < 0 end end @@ -102,7 +102,7 @@ RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_st rebalance_caching.track_new_running_rebalance ::Gitlab::Redis::SharedState.with do |redis| - expect(redis.ttl(rebalance_caching.send(:concurrent_running_rebalances_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + expect(redis.ttl(Gitlab::Issues::Rebalancing::State::CONCURRENT_RUNNING_REBALANCES_KEY)).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) end end end @@ -169,7 +169,7 @@ RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_st rebalance_caching.cleanup_cache - expect(check_existing_keys).to eq(0) + expect(check_existing_keys).to eq(1) end end end @@ -183,6 +183,16 @@ RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_st it { expect(rebalance_caching.send(:rebalanced_container_type)).to eq(described_class::NAMESPACE) } it_behaves_like 'issues rebalance caching' + + describe '.fetch_rebalancing_groups_and_projects' do + before do + rebalance_caching.track_new_running_rebalance + end + + it 'caches recently finished rebalance key' do + expect(described_class.fetch_rebalancing_groups_and_projects).to eq([[group.id], []]) + end + end end context 'rebalancing issues in a project' do @@ -193,6 +203,16 @@ RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_st it { expect(rebalance_caching.send(:rebalanced_container_type)).to eq(described_class::PROJECT) } it_behaves_like 'issues rebalance caching' + + describe '.fetch_rebalancing_groups_and_projects' do + before do + rebalance_caching.track_new_running_rebalance + end + + it 'caches recently finished rebalance key' do + expect(described_class.fetch_rebalancing_groups_and_projects).to eq([[], [project.id]]) + end + end end # count - how many issue ids to generate, issue ids will start at 1 @@ -212,11 +232,14 @@ RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_st def check_existing_keys index = 0 + # spec only, we do not actually scan keys in the code + recently_finished_keys_count = Gitlab::Redis::SharedState.with { |redis| redis.scan(0, match: "#{described_class::RECENTLY_FINISHED_REBALANCE_PREFIX}:*") }.last.count index += 1 if rebalance_caching.get_current_index > 0 index += 1 if rebalance_caching.get_current_project_id.present? index += 1 if rebalance_caching.get_cached_issue_ids(0, 100).present? index += 1 if rebalance_caching.rebalance_in_progress? + index += 1 if recently_finished_keys_count > 0 index end diff --git a/spec/lib/gitlab/lograge/custom_options_spec.rb b/spec/lib/gitlab/lograge/custom_options_spec.rb index 9daedfc37e4..a4ae39a835a 100644 --- a/spec/lib/gitlab/lograge/custom_options_spec.rb +++ b/spec/lib/gitlab/lograge/custom_options_spec.rb @@ -19,7 +19,13 @@ RSpec.describe Gitlab::Lograge::CustomOptions do user_id: 'test', cf_ray: SecureRandom.hex, cf_request_id: SecureRandom.hex, - metadata: { 'meta.user' => 'jane.doe' } + metadata: { 'meta.user' => 'jane.doe' }, + request_urgency: :default, + target_duration_s: 1, + remote_ip: '192.168.1.2', + ua: 'Nyxt', + queue_duration_s: 0.2, + etag_route: '/etag' } end @@ -66,6 +72,18 @@ RSpec.describe Gitlab::Lograge::CustomOptions do end end + context 'trusted payload' do + it { is_expected.to include(event_payload.slice(*described_class::KNOWN_PAYLOAD_PARAMS)) } + + context 'payload with rejected fields' do + let(:event_payload) { { params: {}, request_urgency: :high, something: 'random', username: nil } } + + it { is_expected.to include({ request_urgency: :high }) } + it { is_expected.not_to include({ something: 'random' }) } + it { is_expected.not_to include({ username: nil }) } + end + end + context 'when correlation_id is overridden' do let(:correlation_id_key) { Labkit::Correlation::CorrelationId::LOG_KEY } diff --git a/spec/lib/gitlab/merge_requests/merge_commit_message_spec.rb b/spec/lib/gitlab/merge_requests/merge_commit_message_spec.rb new file mode 100644 index 00000000000..884f8df5e56 --- /dev/null +++ b/spec/lib/gitlab/merge_requests/merge_commit_message_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::MergeRequests::MergeCommitMessage do + let(:merge_commit_template) { nil } + let(:project) { create(:project, :public, :repository, merge_commit_template: merge_commit_template) } + let(:user) { project.creator } + let(:merge_request_description) { "Merge Request Description\nNext line" } + let(:merge_request_title) { 'Bugfix' } + let(:merge_request) do + create( + :merge_request, + :simple, + source_project: project, + target_project: project, + author: user, + description: merge_request_description, + title: merge_request_title + ) + end + + subject { described_class.new(merge_request: merge_request) } + + it 'returns nil when template is not set in target project' do + expect(subject.message).to be_nil + end + + context 'when project has custom merge commit template' do + let(:merge_commit_template) { <<~MSG.rstrip } + %{title} + + See merge request %{reference} + MSG + + it 'uses custom template' do + expect(subject.message).to eq <<~MSG.rstrip + Bugfix + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + end + + context 'when project has merge commit template with closed issues' do + let(:merge_commit_template) { <<~MSG.rstrip } + Merge branch '%{source_branch}' into '%{target_branch}' + + %{title} + + %{issues} + + See merge request %{reference} + MSG + + it 'omits issues and new lines when no issues are mentioned in description' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + + context 'when MR closes issues' do + let(:issue_1) { create(:issue, project: project) } + let(:issue_2) { create(:issue, project: project) } + let(:merge_request_description) { "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}" } + + it 'includes them and keeps new line characters' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + Closes #{issue_1.to_reference} and #{issue_2.to_reference} + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + end + end + + context 'when project has merge commit template with description' do + let(:merge_commit_template) { <<~MSG.rstrip } + Merge branch '%{source_branch}' into '%{target_branch}' + + %{title} + + %{description} + + See merge request %{reference} + MSG + + it 'uses template' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + Merge Request Description + Next line + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + + context 'when description is empty string' do + let(:merge_request_description) { '' } + + it 'skips description placeholder and removes new line characters before it' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + end + + context 'when description is nil' do + let(:merge_request_description) { nil } + + it 'skips description placeholder and removes new line characters before it' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + end + + context 'when description is blank string' do + let(:merge_request_description) { "\n\r \n" } + + it 'skips description placeholder and removes new line characters before it' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + end + end + + context 'when custom merge commit template contains placeholder in the middle or beginning of the line' do + let(:merge_commit_template) { <<~MSG.rstrip } + Merge branch '%{source_branch}' into '%{target_branch}' + + %{description} %{title} + + See merge request %{reference} + MSG + + it 'uses custom template' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Merge Request Description + Next line Bugfix + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + + context 'when description is empty string' do + let(:merge_request_description) { '' } + + it 'does not remove new line characters before empty placeholder' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + end + end + + context 'when project has template with CRLF newlines' do + let(:merge_commit_template) do + "Merge branch '%{source_branch}' into '%{target_branch}'\r\n\r\n%{title}\r\n\r\n%{description}\r\n\r\nSee merge request %{reference}" + end + + it 'converts it to LF newlines' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + Merge Request Description + Next line + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + + context 'when description is empty string' do + let(:merge_request_description) { '' } + + it 'skips description placeholder and removes new line characters before it' do + expect(subject.message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Bugfix + + See merge request #{merge_request.to_reference(full: true)} + MSG + end + end + end +end diff --git a/spec/lib/gitlab/metrics/background_transaction_spec.rb b/spec/lib/gitlab/metrics/background_transaction_spec.rb index d36ee24fc50..83bee84df99 100644 --- a/spec/lib/gitlab/metrics/background_transaction_spec.rb +++ b/spec/lib/gitlab/metrics/background_transaction_spec.rb @@ -4,27 +4,28 @@ require 'spec_helper' RSpec.describe Gitlab::Metrics::BackgroundTransaction do let(:transaction) { described_class.new } - let(:prometheus_metric) { instance_double(Prometheus::Client::Metric, base_labels: {}) } - - before do - allow(described_class).to receive(:prometheus_metric).and_return(prometheus_metric) - end describe '#run' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Metric, base_labels: {}) } + + before do + allow(described_class).to receive(:prometheus_metric).and_return(prometheus_metric) + end + it 'yields the supplied block' do expect { |b| transaction.run(&b) }.to yield_control end it 'stores the transaction in the current thread' do transaction.run do - expect(Thread.current[described_class::BACKGROUND_THREAD_KEY]).to eq(transaction) + expect(Thread.current[described_class::THREAD_KEY]).to eq(transaction) end end it 'removes the transaction from the current thread upon completion' do transaction.run { } - expect(Thread.current[described_class::BACKGROUND_THREAD_KEY]).to be_nil + expect(Thread.current[described_class::THREAD_KEY]).to be_nil end end @@ -68,7 +69,10 @@ RSpec.describe Gitlab::Metrics::BackgroundTransaction do end end - RSpec.shared_examples 'metric with labels' do |metric_method| + it_behaves_like 'transaction metrics with labels' do + let(:transaction_obj) { described_class.new } + let(:labels) { { endpoint_id: 'TestWorker', feature_category: 'projects', queue: 'test_worker' } } + before do test_worker_class = Class.new do def self.queue @@ -78,33 +82,10 @@ RSpec.describe Gitlab::Metrics::BackgroundTransaction do stub_const('TestWorker', test_worker_class) end - it 'measures with correct labels and value' do - value = 1 - expect(prometheus_metric).to receive(metric_method).with({ - endpoint_id: 'TestWorker', feature_category: 'projects', queue: 'test_worker' - }, value) - + around do |example| Gitlab::ApplicationContext.with_raw_context(feature_category: 'projects', caller_id: 'TestWorker') do - transaction.send(metric_method, :test_metric, value) + example.run end end end - - describe '#increment' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, :increment, base_labels: {}) } - - it_behaves_like 'metric with labels', :increment - end - - describe '#set' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Gauge, :set, base_labels: {}) } - - it_behaves_like 'metric with labels', :set - end - - describe '#observe' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Histogram, :observe, base_labels: {}) } - - it_behaves_like 'metric with labels', :observe - end end diff --git a/spec/lib/gitlab/metrics/method_call_spec.rb b/spec/lib/gitlab/metrics/method_call_spec.rb index fb5436a90e3..6aa89c7cb05 100644 --- a/spec/lib/gitlab/metrics/method_call_spec.rb +++ b/spec/lib/gitlab/metrics/method_call_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Gitlab::Metrics::MethodCall do it 'metric is not a NullMetric' do method_call.measure { 'foo' } - expect(::Gitlab::Metrics::Transaction.prometheus_metric(:gitlab_method_call_duration_seconds, :histogram)).not_to be_instance_of(Gitlab::Metrics::NullMetric) + expect(::Gitlab::Metrics::WebTransaction.prometheus_metric(:gitlab_method_call_duration_seconds, :histogram)).not_to be_instance_of(Gitlab::Metrics::NullMetric) end it 'observes the performance of the supplied block' do @@ -63,7 +63,7 @@ RSpec.describe Gitlab::Metrics::MethodCall do it 'observes using NullMetric' do method_call.measure { 'foo' } - expect(::Gitlab::Metrics::Transaction.prometheus_metric(:gitlab_method_call_duration_seconds, :histogram)).to be_instance_of(Gitlab::Metrics::NullMetric) + expect(::Gitlab::Metrics::WebTransaction.prometheus_metric(:gitlab_method_call_duration_seconds, :histogram)).to be_instance_of(Gitlab::Metrics::NullMetric) end end end diff --git a/spec/lib/gitlab/metrics/rails_slis_spec.rb b/spec/lib/gitlab/metrics/rails_slis_spec.rb index 16fcb9d46a2..a5ccf7fafa4 100644 --- a/spec/lib/gitlab/metrics/rails_slis_spec.rb +++ b/spec/lib/gitlab/metrics/rails_slis_spec.rb @@ -10,49 +10,62 @@ RSpec.describe Gitlab::Metrics::RailsSlis do 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::Graphql::KnownOperations).to receive(:default).and_return(Gitlab::Graphql::KnownOperations.new(%w(foo bar))) end describe '.initialize_request_slis_if_needed!' do - it "initializes the SLI for all possible endpoints if they weren't" do + it "initializes the SLI for all possible endpoints if they weren't", :aggregate_failures do possible_labels = [ { endpoint_id: "GET /api/:version/version", - feature_category: :not_owned + feature_category: :not_owned, + request_urgency: :default }, { endpoint_id: "ProjectsController#show", - feature_category: :projects + feature_category: :projects, + request_urgency: :default } ] + possible_graphql_labels = ['graphql:foo', 'graphql:bar', 'graphql:unknown', 'graphql:anonymous'].map do |endpoint_id| + { + endpoint_id: endpoint_id, + feature_category: nil, + query_urgency: ::Gitlab::EndpointAttributes::DEFAULT_URGENCY.name + } + end + expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:rails_request_apdex) { false } + expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:graphql_query_apdex) { false } expect(Gitlab::Metrics::Sli).to receive(:initialize_sli).with(:rails_request_apdex, array_including(*possible_labels)).and_call_original + expect(Gitlab::Metrics::Sli).to receive(:initialize_sli).with(:graphql_query_apdex, array_including(*possible_graphql_labels)).and_call_original described_class.initialize_request_slis_if_needed! end - it 'does not initialize the SLI if they were initialized already' do + it 'does not initialize the SLI if they were initialized already', :aggregate_failures do expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:rails_request_apdex) { true } + expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:graphql_query_apdex) { true } expect(Gitlab::Metrics::Sli).not_to receive(:initialize_sli) described_class.initialize_request_slis_if_needed! end + end - it 'does not initialize anything if the feature flag is disabled' do - stub_feature_flags(request_apdex_counters: false) - - expect(Gitlab::Metrics::Sli).not_to receive(:initialize_sli) - expect(Gitlab::Metrics::Sli).not_to receive(:initialized?) - + describe '.request_apdex' do + it 'returns the initialized request apdex SLI object' do described_class.initialize_request_slis_if_needed! + + expect(described_class.request_apdex).to be_initialized end end - describe '.request_apdex' do + describe '.graphql_query_apdex' do it 'returns the initialized request apdex SLI object' do described_class.initialize_request_slis_if_needed! - expect(described_class.request_apdex).to be_initialized + expect(described_class.graphql_query_apdex).to be_initialized end end end diff --git a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb index 5870f9a8f68..3396de9b12c 100644 --- a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb @@ -36,7 +36,8 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do it 'tracks request count and duration' do expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'unknown') expect(described_class).to receive_message_chain(:http_request_duration_seconds, :observe).with({ method: 'get' }, a_positive_execution_time) - expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with(labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, success: true) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment) + .with(labels: { feature_category: 'unknown', endpoint_id: 'unknown', request_urgency: :default }, success: true) subject.call(env) end @@ -115,14 +116,14 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do context 'application context' do context 'when a context is present' do before do - ::Gitlab::ApplicationContext.push(feature_category: 'issue_tracking', caller_id: 'IssuesController#show') + ::Gitlab::ApplicationContext.push(feature_category: 'team_planning', caller_id: 'IssuesController#show') end it 'adds the feature category to the labels for required metrics' do - expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'issue_tracking') + expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'team_planning') expect(described_class).not_to receive(:http_health_requests_total) expect(Gitlab::Metrics::RailsSlis.request_apdex) - .to receive(:increment).with(labels: { feature_category: 'issue_tracking', endpoint_id: 'IssuesController#show' }, success: true) + .to receive(:increment).with(labels: { feature_category: 'team_planning', endpoint_id: 'IssuesController#show', request_urgency: :default }, success: true) subject.call(env) end @@ -140,12 +141,12 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do context 'when application raises an exception when the feature category context is present' do before do - ::Gitlab::ApplicationContext.push(feature_category: 'issue_tracking') + ::Gitlab::ApplicationContext.push(feature_category: 'team_planning') allow(app).to receive(:call).and_raise(StandardError) end it 'adds the feature category to the labels for http_requests_total' do - expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: 'undefined', feature_category: 'issue_tracking') + expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: 'undefined', feature_category: 'team_planning') expect(Gitlab::Metrics::RailsSlis).not_to receive(:request_apdex) expect { subject.call(env) }.to raise_error(StandardError) @@ -156,7 +157,8 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do it 'sets the required labels to unknown' do expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'unknown') expect(described_class).not_to receive(:http_health_requests_total) - expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with(labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, success: true) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment) + .with(labels: { feature_category: 'unknown', endpoint_id: 'unknown', request_urgency: :default }, success: true) subject.call(env) end @@ -206,7 +208,11 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do it "captures SLI metrics" do expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( - labels: { feature_category: 'hello_world', endpoint_id: 'GET /projects/:id/archive' }, + labels: { + feature_category: 'hello_world', + endpoint_id: 'GET /projects/:id/archive', + request_urgency: request_urgency_name + }, success: success ) subject.call(env) @@ -235,7 +241,11 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do it "captures SLI metrics" do expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( - labels: { feature_category: 'hello_world', endpoint_id: 'AnonymousController#index' }, + labels: { + feature_category: 'hello_world', + endpoint_id: 'AnonymousController#index', + request_urgency: request_urgency_name + }, success: success ) subject.call(env) @@ -255,17 +265,25 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do let(:api_handler) { Class.new(::API::Base) } - it "falls back request's expectation to medium (1 second)" do + it "falls back request's expectation to default (1 second)" do allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 100.9) expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( - labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + labels: { + feature_category: 'unknown', + endpoint_id: 'unknown', + request_urgency: :default + }, success: true ) subject.call(env) allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 101) expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( - labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + labels: { + feature_category: 'unknown', + endpoint_id: 'unknown', + request_urgency: :default + }, success: false ) subject.call(env) @@ -281,17 +299,25 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do { 'action_controller.instance' => controller_instance, 'REQUEST_METHOD' => 'GET' } end - it "falls back request's expectation to medium (1 second)" do + it "falls back request's expectation to default (1 second)" do allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 100.9) expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( - labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + labels: { + feature_category: 'unknown', + endpoint_id: 'unknown', + request_urgency: :default + }, success: true ) subject.call(env) allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 101) expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( - labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + labels: { + feature_category: 'unknown', + endpoint_id: 'unknown', + request_urgency: :default + }, success: false ) subject.call(env) @@ -303,17 +329,25 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do { 'REQUEST_METHOD' => 'GET' } end - it "falls back request's expectation to medium (1 second)" do + it "falls back request's expectation to default (1 second)" do allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 100.9) expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( - labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + labels: { + feature_category: 'unknown', + endpoint_id: 'unknown', + request_urgency: :default + }, success: true ) subject.call(env) allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 101) expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( - labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + labels: { + feature_category: 'unknown', + endpoint_id: 'unknown', + request_urgency: :default + }, success: false ) subject.call(env) diff --git a/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb index f751416f4ec..d834b796179 100644 --- a/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb @@ -23,64 +23,46 @@ RSpec.describe Gitlab::Metrics::Samplers::ActionCableSampler do allow(pool).to receive(:queue_length).and_return(6) end - shared_examples 'collects metrics' do |expected_labels| - it 'includes active connections' do - expect(subject.metrics[:active_connections]).to receive(:set).with(expected_labels, 0) + it 'includes active connections' do + expect(subject.metrics[:active_connections]).to receive(:set).with({}, 0) - subject.sample - end - - it 'includes minimum worker pool size' do - expect(subject.metrics[:pool_min_size]).to receive(:set).with(expected_labels, 1) - - subject.sample - end - - it 'includes maximum worker pool size' do - expect(subject.metrics[:pool_max_size]).to receive(:set).with(expected_labels, 2) - - subject.sample - end + subject.sample + end - it 'includes current worker pool size' do - expect(subject.metrics[:pool_current_size]).to receive(:set).with(expected_labels, 3) + it 'includes minimum worker pool size' do + expect(subject.metrics[:pool_min_size]).to receive(:set).with({}, 1) - subject.sample - end + subject.sample + end - it 'includes largest worker pool size' do - expect(subject.metrics[:pool_largest_size]).to receive(:set).with(expected_labels, 4) + it 'includes maximum worker pool size' do + expect(subject.metrics[:pool_max_size]).to receive(:set).with({}, 2) - subject.sample - end + subject.sample + end - it 'includes worker pool completed task count' do - expect(subject.metrics[:pool_completed_tasks]).to receive(:set).with(expected_labels, 5) + it 'includes current worker pool size' do + expect(subject.metrics[:pool_current_size]).to receive(:set).with({}, 3) - subject.sample - end + subject.sample + end - it 'includes worker pool pending task count' do - expect(subject.metrics[:pool_pending_tasks]).to receive(:set).with(expected_labels, 6) + it 'includes largest worker pool size' do + expect(subject.metrics[:pool_largest_size]).to receive(:set).with({}, 4) - subject.sample - end + subject.sample end - context 'for in-app mode' do - before do - expect(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(true) - end + it 'includes worker pool completed task count' do + expect(subject.metrics[:pool_completed_tasks]).to receive(:set).with({}, 5) - it_behaves_like 'collects metrics', server_mode: 'in-app' + subject.sample end - context 'for standalone mode' do - before do - expect(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(false) - end + it 'includes worker pool pending task count' do + expect(subject.metrics[:pool_pending_tasks]).to receive(:set).with({}, 6) - it_behaves_like 'collects metrics', server_mode: 'standalone' + subject.sample end end end diff --git a/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb index 7dda10ab41d..e97a4fdddcb 100644 --- a/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb @@ -18,8 +18,8 @@ RSpec.describe Gitlab::Metrics::Samplers::DatabaseSampler do let(:labels) do { class: 'ActiveRecord::Base', - host: Gitlab::Database.main.config['host'], - port: Gitlab::Database.main.config['port'] + host: ApplicationRecord.database.config['host'], + port: ApplicationRecord.database.config['port'] } end diff --git a/spec/lib/gitlab/metrics/subscribers/external_http_spec.rb b/spec/lib/gitlab/metrics/subscribers/external_http_spec.rb index adbc05cb711..e489ac97b9c 100644 --- a/spec/lib/gitlab/metrics/subscribers/external_http_spec.rb +++ b/spec/lib/gitlab/metrics/subscribers/external_http_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::Metrics::Subscribers::ExternalHttp, :request_store do - let(:transaction) { Gitlab::Metrics::Transaction.new } + let(:transaction) { Gitlab::Metrics::WebTransaction.new({}) } let(:subscriber) { described_class.new } around do |example| diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb index 2ff8efcd7cb..b1c15db5193 100644 --- a/spec/lib/gitlab/metrics/transaction_spec.rb +++ b/spec/lib/gitlab/metrics/transaction_spec.rb @@ -3,172 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::Metrics::Transaction do - let(:transaction) { described_class.new } - - let(:sensitive_tags) do - { - path: 'private', - branch: 'sensitive' - } - end - - describe '#method_call_for' do - it 'returns a MethodCall' do - method = transaction.method_call_for('Foo#bar', :Foo, '#bar') - - expect(method).to be_an_instance_of(Gitlab::Metrics::MethodCall) - end - end - describe '#run' do - specify { expect { transaction.run }.to raise_error(NotImplementedError) } - end - - describe '#add_event' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, increment: nil, base_labels: {}) } - - it 'adds a metric' do - expect(prometheus_metric).to receive(:increment) - expect(described_class).to receive(:fetch_metric).with(:counter, :gitlab_transaction_event_meow_total).and_return(prometheus_metric) - - transaction.add_event(:meow) - end - - it 'allows tracking of custom tags' do - expect(prometheus_metric).to receive(:increment).with(hash_including(animal: "dog")) - expect(described_class).to receive(:fetch_metric).with(:counter, :gitlab_transaction_event_bau_total).and_return(prometheus_metric) - - transaction.add_event(:bau, animal: 'dog') - end - - context 'with sensitive tags' do - before do - transaction.add_event(:baubau, **sensitive_tags.merge(sane: 'yes')) - allow(described_class).to receive(:prometheus_metric).and_return(prometheus_metric) - end - - it 'filters tags' do - expect(prometheus_metric).not_to receive(:increment).with(hash_including(sensitive_tags)) - - transaction.add_event(:baubau, **sensitive_tags.merge(sane: 'yes')) - end - end - end - - describe '#increment' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, increment: nil, base_labels: {}) } - - it 'adds a metric' do - expect(prometheus_metric).to receive(:increment) - expect(::Gitlab::Metrics).to receive(:counter).with(:meow, 'Meow counter', hash_including(:controller, :action)).and_return(prometheus_metric) - - transaction.increment(:meow, 1) - end - - context 'with block' do - it 'overrides docstring' do - expect(::Gitlab::Metrics).to receive(:counter).with(:block_docstring, 'test', hash_including(:controller, :action)).and_return(prometheus_metric) - - transaction.increment(:block_docstring, 1) do - docstring 'test' - end - end - - it 'overrides labels' do - expect(::Gitlab::Metrics).to receive(:counter).with(:block_labels, 'Block labels counter', hash_including(:controller, :action, :sane)).and_return(prometheus_metric) - - labels = { sane: 'yes' } - transaction.increment(:block_labels, 1, labels) do - label_keys %i(sane) - end - end - - it 'filters sensitive tags' do - expect(::Gitlab::Metrics).to receive(:counter).with(:metric_with_sensitive_block, 'Metric with sensitive block counter', hash_excluding(sensitive_tags)).and_return(prometheus_metric) - - labels_keys = sensitive_tags.keys - transaction.increment(:metric_with_sensitive_block, 1, sensitive_tags) do - label_keys labels_keys - end - end - end - end - - describe '#set' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Gauge, set: nil, base_labels: {}) } - - it 'adds a metric' do - expect(prometheus_metric).to receive(:set) - expect(::Gitlab::Metrics).to receive(:gauge).with(:meow_set, 'Meow set gauge', hash_including(:controller, :action), :all).and_return(prometheus_metric) - - transaction.set(:meow_set, 1) - end - - context 'with block' do - it 'overrides docstring' do - expect(::Gitlab::Metrics).to receive(:gauge).with(:block_docstring_set, 'test', hash_including(:controller, :action), :all).and_return(prometheus_metric) - - transaction.set(:block_docstring_set, 1) do - docstring 'test' - end - end - - it 'overrides labels' do - expect(::Gitlab::Metrics).to receive(:gauge).with(:block_labels_set, 'Block labels set gauge', hash_including(:controller, :action, :sane), :all).and_return(prometheus_metric) - - labels = { sane: 'yes' } - transaction.set(:block_labels_set, 1, labels) do - label_keys %i(sane) - end - end - - it 'filters sensitive tags' do - expect(::Gitlab::Metrics).to receive(:gauge).with(:metric_set_with_sensitive_block, 'Metric set with sensitive block gauge', hash_excluding(sensitive_tags), :all).and_return(prometheus_metric) - - label_keys = sensitive_tags.keys - transaction.set(:metric_set_with_sensitive_block, 1, sensitive_tags) do - label_keys label_keys - end - end - end - end - - describe '#observe' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Histogram, observe: nil, base_labels: {}) } - - it 'adds a metric' do - expect(prometheus_metric).to receive(:observe) - expect(::Gitlab::Metrics).to receive(:histogram).with(:meow_observe, 'Meow observe histogram', hash_including(:controller, :action), kind_of(Array)).and_return(prometheus_metric) - - transaction.observe(:meow_observe, 1) - end - - context 'with block' do - it 'overrides docstring' do - expect(::Gitlab::Metrics).to receive(:histogram).with(:block_docstring_observe, 'test', hash_including(:controller, :action), kind_of(Array)).and_return(prometheus_metric) - - transaction.observe(:block_docstring_observe, 1) do - docstring 'test' - end - end - - it 'overrides labels' do - expect(::Gitlab::Metrics).to receive(:histogram).with(:block_labels_observe, 'Block labels observe histogram', hash_including(:controller, :action, :sane), kind_of(Array)).and_return(prometheus_metric) - - labels = { sane: 'yes' } - transaction.observe(:block_labels_observe, 1, labels) do - label_keys %i(sane) - end - end - - it 'filters sensitive tags' do - expect(::Gitlab::Metrics).to receive(:histogram).with(:metric_observe_with_sensitive_block, 'Metric observe with sensitive block histogram', hash_excluding(sensitive_tags), kind_of(Array)).and_return(prometheus_metric) - - label_keys = sensitive_tags.keys - transaction.observe(:metric_observe_with_sensitive_block, 1, sensitive_tags) do - label_keys label_keys - end - end - end + specify { expect { described_class.new.run }.to raise_error(NotImplementedError) } end end diff --git a/spec/lib/gitlab/metrics/web_transaction_spec.rb b/spec/lib/gitlab/metrics/web_transaction_spec.rb index 9e22dccb2a2..06ce58a9e84 100644 --- a/spec/lib/gitlab/metrics/web_transaction_spec.rb +++ b/spec/lib/gitlab/metrics/web_transaction_spec.rb @@ -5,41 +5,14 @@ require 'spec_helper' RSpec.describe Gitlab::Metrics::WebTransaction do let(:env) { {} } let(:transaction) { described_class.new(env) } - let(:prometheus_metric) { instance_double(Prometheus::Client::Metric, base_labels: {}) } - before do - allow(described_class).to receive(:prometheus_metric).and_return(prometheus_metric) - end - - RSpec.shared_context 'ActionController request' do - let(:request) { double(:request, format: double(:format, ref: :html)) } - let(:controller_class) { double(:controller_class, name: 'TestController') } - - before do - controller = double(:controller, class: controller_class, action_name: 'show', request: request) - env['action_controller.instance'] = controller - end - end + describe '#run' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Metric, base_labels: {}) } - RSpec.shared_context 'transaction observe metrics' do before do + allow(described_class).to receive(:prometheus_metric).and_return(prometheus_metric) allow(transaction).to receive(:observe) end - end - - RSpec.shared_examples 'metric with labels' do |metric_method| - include_context 'ActionController request' - - it 'measures with correct labels and value' do - value = 1 - expect(prometheus_metric).to receive(metric_method).with({ controller: 'TestController', action: 'show', feature_category: ::Gitlab::FeatureCategories::FEATURE_CATEGORY_DEFAULT }, value) - - transaction.send(metric_method, :bau, value) - end - end - - describe '#run' do - include_context 'transaction observe metrics' it 'yields the supplied block' do expect { |b| transaction.run(&b) }.to yield_control @@ -88,14 +61,6 @@ RSpec.describe Gitlab::Metrics::WebTransaction do end end - describe '#method_call_for' do - it 'returns a MethodCall' do - method = transaction.method_call_for('Foo#bar', :Foo, '#bar') - - expect(method).to be_an_instance_of(Gitlab::Metrics::MethodCall) - end - end - describe '#labels' do context 'when request goes to Grape endpoint' do before do @@ -115,7 +80,7 @@ RSpec.describe Gitlab::Metrics::WebTransaction do end it 'contains only the labels defined for transactions' do - expect(transaction.labels.keys).to contain_exactly(*described_class.superclass::BASE_LABEL_KEYS) + expect(transaction.labels.keys).to contain_exactly(*described_class::BASE_LABEL_KEYS) end it 'does not provide labels if route infos are missing' do @@ -129,14 +94,20 @@ RSpec.describe Gitlab::Metrics::WebTransaction do end context 'when request goes to ActionController' do - include_context 'ActionController request' + let(:request) { double(:request, format: double(:format, ref: :html)) } + let(:controller_class) { double(:controller_class, name: 'TestController') } + + before do + controller = double(:controller, class: controller_class, action_name: 'show', request: request) + env['action_controller.instance'] = controller + end it 'tags a transaction with the name and action of a controller' do expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: ::Gitlab::FeatureCategories::FEATURE_CATEGORY_DEFAULT }) end it 'contains only the labels defined for transactions' do - expect(transaction.labels.keys).to contain_exactly(*described_class.superclass::BASE_LABEL_KEYS) + expect(transaction.labels.keys).to contain_exactly(*described_class::BASE_LABEL_KEYS) end context 'when the request content type is not :html' do @@ -170,37 +141,16 @@ RSpec.describe Gitlab::Metrics::WebTransaction do end end - describe '#add_event' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, :increment, base_labels: {}) } - - it 'adds a metric' do - expect(prometheus_metric).to receive(:increment) - - transaction.add_event(:meow) - end + it_behaves_like 'transaction metrics with labels' do + let(:request) { double(:request, format: double(:format, ref: :html)) } + let(:controller_class) { double(:controller_class, name: 'TestController') } + let(:controller) { double(:controller, class: controller_class, action_name: 'show', request: request) } - it 'allows tracking of custom tags' do - expect(prometheus_metric).to receive(:increment).with(animal: "dog") + let(:transaction_obj) { described_class.new({ 'action_controller.instance' => controller }) } + let(:labels) { { controller: 'TestController', action: 'show', feature_category: 'projects' } } - transaction.add_event(:bau, animal: 'dog') + before do + ::Gitlab::ApplicationContext.push(feature_category: 'projects') end end - - describe '#increment' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, :increment, base_labels: {}) } - - it_behaves_like 'metric with labels', :increment - end - - describe '#set' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Gauge, :set, base_labels: {}) } - - it_behaves_like 'metric with labels', :set - end - - describe '#observe' do - let(:prometheus_metric) { instance_double(Prometheus::Client::Histogram, :observe, base_labels: {}) } - - it_behaves_like 'metric with labels', :observe - end end diff --git a/spec/lib/gitlab/middleware/compressed_json_spec.rb b/spec/lib/gitlab/middleware/compressed_json_spec.rb new file mode 100644 index 00000000000..c5efc568971 --- /dev/null +++ b/spec/lib/gitlab/middleware/compressed_json_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Middleware::CompressedJson do + let_it_be(:decompressed_input) { '{"foo": "bar"}' } + let_it_be(:input) { ActiveSupport::Gzip.compress(decompressed_input) } + + let(:app) { double(:app) } + let(:middleware) { described_class.new(app) } + let(:env) do + { + 'HTTP_CONTENT_ENCODING' => 'gzip', + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/json', + 'PATH_INFO' => path, + 'rack.input' => StringIO.new(input) + } + end + + shared_examples 'decompress middleware' do + it 'replaces input with a decompressed content' do + expect(app).to receive(:call) + + middleware.call(env) + + expect(env['rack.input'].read).to eq(decompressed_input) + expect(env['CONTENT_LENGTH']).to eq(decompressed_input.length) + expect(env['HTTP_CONTENT_ENCODING']).to be_nil + end + end + + describe '#call' do + context 'with collector route' do + let(:path) { '/api/v4/error_tracking/collector/1/store'} + + it_behaves_like 'decompress middleware' + end + + context 'with collector route under relative url' do + let(:path) { '/gitlab/api/v4/error_tracking/collector/1/store'} + + before do + stub_config_setting(relative_url_root: '/gitlab') + end + + it_behaves_like 'decompress middleware' + end + + context 'with some other route' do + let(:path) { '/api/projects/123' } + + it 'keeps the original input' do + expect(app).to receive(:call) + + middleware.call(env) + + expect(env['rack.input'].read).to eq(input) + expect(env['HTTP_CONTENT_ENCODING']).to eq('gzip') + end + end + + context 'payload is too large' do + let(:body_limit) { Gitlab::Middleware::CompressedJson::MAXIMUM_BODY_SIZE } + let(:decompressed_input) { 'a' * (body_limit + 100) } + let(:input) { ActiveSupport::Gzip.compress(decompressed_input) } + let(:path) { '/api/v4/error_tracking/collector/1/envelope'} + + it 'reads only limited size' do + expect(middleware.call(env)) + .to eq([413, { 'Content-Type' => 'text/plain' }, ['Payload Too Large']]) + end + end + end +end diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb index 0ce95fdb5af..1ef548ab29b 100644 --- a/spec/lib/gitlab/middleware/go_spec.rb +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -147,6 +147,22 @@ RSpec.describe Gitlab::Middleware::Go do end end end + + context 'when a personal access token is missing' do + before do + env['REMOTE_ADDR'] = '192.168.0.1' + env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(current_user.username, 'dummy_password') + end + + it 'returns unauthorized' do + expect(Gitlab::Auth).to receive(:find_for_git_client).and_raise(Gitlab::Auth::MissingPersonalAccessTokenError) + response = go + + expect(response[0]).to eq(401) + expect(response[1]['Content-Length']).to be_nil + expect(response[2]).to eq(['']) + end + end end end end diff --git a/spec/lib/gitlab/middleware/query_analyzer_spec.rb b/spec/lib/gitlab/middleware/query_analyzer_spec.rb new file mode 100644 index 00000000000..5ebe6a92da6 --- /dev/null +++ b/spec/lib/gitlab/middleware/query_analyzer_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Middleware::QueryAnalyzer, query_analyzers: false do + describe 'the PreventCrossDatabaseModification' do + describe '#call' do + let(:app) { double(:app) } + let(:middleware) { described_class.new(app) } + let(:env) { {} } + + subject { middleware.call(env) } + + context 'when there is a cross modification' do + before do + allow(app).to receive(:call) do + Project.transaction do + Project.where(id: -1).update_all(id: -1) + ::Ci::Pipeline.where(id: -1).update_all(id: -1) + end + end + end + + it 'detects cross modifications and tracks exception' do + expect(::Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + + expect { subject }.not_to raise_error + end + + context 'when the detect_cross_database_modification is disabled' do + before do + stub_feature_flags(detect_cross_database_modification: false) + end + + it 'does not detect cross modifications' do + expect(::Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception) + + subject + end + end + end + + context 'when there is no cross modification' do + before do + allow(app).to receive(:call) do + Project.transaction do + Project.where(id: -1).update_all(id: -1) + Namespace.where(id: -1).update_all(id: -1) + end + end + end + + it 'does not log anything' do + expect(::Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception) + + subject + end + end + end + end +end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index 2f38ed58727..f0ba0f0459d 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -425,6 +425,9 @@ RSpec.describe Gitlab::PathRegex do it { is_expected.not_to match('gitlab.org/') } it { is_expected.not_to match('/gitlab.org') } it { is_expected.not_to match('gitlab git') } + it { is_expected.not_to match('gitlab?') } + it { is_expected.to match('gitlab.org-') } + it { is_expected.to match('gitlab.org_') } end describe '.project_path_format_regex' do @@ -437,6 +440,14 @@ RSpec.describe Gitlab::PathRegex do it { is_expected.not_to match('?gitlab') } it { is_expected.not_to match('git lab') } it { is_expected.not_to match('gitlab.git') } + it { is_expected.not_to match('gitlab?') } + it { is_expected.not_to match('gitlab git') } + it { is_expected.to match('gitlab.org') } + it { is_expected.to match('gitlab.org-') } + it { is_expected.to match('gitlab.org_') } + it { is_expected.to match('gitlab.org.') } + it { is_expected.not_to match('gitlab.org/') } + it { is_expected.not_to match('/gitlab.org') } end context 'repository routes' do diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb index 4eb13e63b46..05417e721c7 100644 --- a/spec/lib/gitlab/project_template_spec.rb +++ b/spec/lib/gitlab/project_template_spec.rb @@ -10,8 +10,8 @@ RSpec.describe Gitlab::ProjectTemplate do gomicro gatsby hugo jekyll plainhtml gitbook hexo sse_middleman gitpod_spring_petclinic nfhugo nfjekyll nfplainhtml nfgitbook nfhexo salesforcedx - serverless_framework jsonnet cluster_management - kotlin_native_linux + serverless_framework tencent_serverless_framework + jsonnet cluster_management kotlin_native_linux ] expect(described_class.all).to be_an(Array) diff --git a/spec/lib/gitlab/prometheus_client_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb index 82ef4675553..89ddde4a01d 100644 --- a/spec/lib/gitlab/prometheus_client_spec.rb +++ b/spec/lib/gitlab/prometheus_client_spec.rb @@ -107,36 +107,14 @@ RSpec.describe Gitlab::PrometheusClient do let(:prometheus_url) {"https://prometheus.invalid.example.com/api/v1/query?query=1"} shared_examples 'exceptions are raised' do - it 'raises a Gitlab::PrometheusClient::ConnectionError error when a SocketError is rescued' do - req_stub = stub_prometheus_request_with_exception(prometheus_url, SocketError) + Gitlab::HTTP::HTTP_ERRORS.each do |error| + it "raises a Gitlab::PrometheusClient::ConnectionError when a #{error} is rescued" do + req_stub = stub_prometheus_request_with_exception(prometheus_url, error.new) - expect { subject } - .to raise_error(Gitlab::PrometheusClient::ConnectionError, "Can't connect to #{prometheus_url}") - expect(req_stub).to have_been_requested - end - - it 'raises a Gitlab::PrometheusClient::ConnectionError error when a SSLError is rescued' do - req_stub = stub_prometheus_request_with_exception(prometheus_url, OpenSSL::SSL::SSLError) - - expect { subject } - .to raise_error(Gitlab::PrometheusClient::ConnectionError, "#{prometheus_url} contains invalid SSL data") - expect(req_stub).to have_been_requested - end - - it 'raises a Gitlab::PrometheusClient::ConnectionError error when a Gitlab::HTTP::ResponseError is rescued' do - req_stub = stub_prometheus_request_with_exception(prometheus_url, Gitlab::HTTP::ResponseError) - - expect { subject } - .to raise_error(Gitlab::PrometheusClient::ConnectionError, "Network connection error") - expect(req_stub).to have_been_requested - end - - it 'raises a Gitlab::PrometheusClient::ConnectionError error when a Gitlab::HTTP::ResponseError with a code is rescued' do - req_stub = stub_prometheus_request_with_exception(prometheus_url, Gitlab::HTTP::ResponseError.new(code: 400)) - - expect { subject } - .to raise_error(Gitlab::PrometheusClient::ConnectionError, "Network connection error") - expect(req_stub).to have_been_requested + expect { subject } + .to raise_error(Gitlab::PrometheusClient::ConnectionError, kind_of(String)) + expect(req_stub).to have_been_requested + end end end diff --git a/spec/lib/gitlab/redis/multi_store_spec.rb b/spec/lib/gitlab/redis/multi_store_spec.rb new file mode 100644 index 00000000000..bf1bf65bb9b --- /dev/null +++ b/spec/lib/gitlab/redis/multi_store_spec.rb @@ -0,0 +1,474 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Redis::MultiStore do + using RSpec::Parameterized::TableSyntax + + let_it_be(:redis_store_class) do + Class.new(Gitlab::Redis::Wrapper) do + def config_file_name + config_file_name = "spec/fixtures/config/redis_new_format_host.yml" + Rails.root.join(config_file_name).to_s + end + + def self.name + 'Sessions' + end + end + end + + let_it_be(:primary_db) { 1 } + let_it_be(:secondary_db) { 2 } + let_it_be(:primary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) } + let_it_be(:secondary_store) { create_redis_store(redis_store_class.params, db: secondary_db, serializer: nil) } + let_it_be(:instance_name) { 'TestStore' } + let_it_be(:multi_store) { described_class.new(primary_store, secondary_store, instance_name)} + + subject { multi_store.send(name, *args) } + + after(:all) do + primary_store.flushdb + secondary_store.flushdb + end + + context 'when primary_store is nil' do + let(:multi_store) { described_class.new(nil, secondary_store, instance_name)} + + it 'fails with exception' do + expect { multi_store }.to raise_error(ArgumentError, /primary_store is required/) + end + end + + context 'when secondary_store is nil' do + let(:multi_store) { described_class.new(primary_store, nil, instance_name)} + + it 'fails with exception' do + expect { multi_store }.to raise_error(ArgumentError, /secondary_store is required/) + end + end + + context 'when primary_store is not a ::Redis instance' do + before do + allow(primary_store).to receive(:is_a?).with(::Redis).and_return(false) + end + + it 'fails with exception' do + expect { described_class.new(primary_store, secondary_store, instance_name) }.to raise_error(ArgumentError, /invalid primary_store/) + end + end + + context 'when secondary_store is not a ::Redis instance' do + before do + allow(secondary_store).to receive(:is_a?).with(::Redis).and_return(false) + end + + it 'fails with exception' do + expect { described_class.new(primary_store, secondary_store, instance_name) }.to raise_error(ArgumentError, /invalid secondary_store/) + end + end + + context 'with READ redis commands' do + let_it_be(:key1) { "redis:{1}:key_a" } + let_it_be(:key2) { "redis:{1}:key_b" } + let_it_be(:value1) { "redis_value1"} + let_it_be(:value2) { "redis_value2"} + let_it_be(:skey) { "redis:set:key" } + let_it_be(:keys) { [key1, key2] } + let_it_be(:values) { [value1, value2] } + let_it_be(:svalues) { [value2, value1] } + + where(:case_name, :name, :args, :value, :block) do + 'execute :get command' | :get | ref(:key1) | ref(:value1) | nil + 'execute :mget command' | :mget | ref(:keys) | ref(:values) | nil + 'execute :mget with block' | :mget | ref(:keys) | ref(:values) | ->(value) { value } + 'execute :smembers command' | :smembers | ref(:skey) | ref(:svalues) | nil + 'execute :scard command' | :scard | ref(:skey) | 2 | nil + end + + before(:all) do + primary_store.multi do |multi| + multi.set(key1, value1) + multi.set(key2, value2) + multi.sadd(skey, value1) + multi.sadd(skey, value2) + end + + secondary_store.multi do |multi| + multi.set(key1, value1) + multi.set(key2, value2) + multi.sadd(skey, value1) + multi.sadd(skey, value2) + end + end + + RSpec.shared_examples_for 'reads correct value' do + it 'returns the correct value' do + if value.is_a?(Array) + # :smembers does not guarantee the order it will return the values (unsorted set) + is_expected.to match_array(value) + else + is_expected.to eq(value) + end + end + end + + RSpec.shared_examples_for 'fallback read from the secondary store' do + it 'fallback and execute on secondary instance' do + expect(secondary_store).to receive(name).with(*args).and_call_original + + subject + end + + it 'logs the ReadFromPrimaryError' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(Gitlab::Redis::MultiStore::ReadFromPrimaryError), + hash_including(command_name: name, extra: hash_including(instance_name: instance_name))) + + subject + end + + it 'increment read fallback count metrics' do + expect(multi_store).to receive(:increment_read_fallback_count).with(name) + + subject + end + + include_examples 'reads correct value' + + context 'when fallback read from the secondary instance raises an exception' do + before do + allow(secondary_store).to receive(name).with(*args).and_raise(StandardError) + end + + it 'fails with exception' do + expect { subject }.to raise_error(StandardError) + end + end + end + + RSpec.shared_examples_for 'secondary store' do + it 'execute on the secondary instance' do + expect(secondary_store).to receive(name).with(*args).and_call_original + + subject + end + + include_examples 'reads correct value' + + it 'does not execute on the primary store' do + expect(primary_store).not_to receive(name) + + subject + end + end + + with_them do + describe "#{name}" do + before do + allow(primary_store).to receive(name).and_call_original + allow(secondary_store).to receive(name).and_call_original + end + + context 'with feature flag :use_multi_store enabled' do + before do + stub_feature_flags(use_multi_store: true) + end + + context 'when reading from the primary is successful' do + it 'returns the correct value' do + expect(primary_store).to receive(name).with(*args).and_call_original + + subject + end + + it 'does not execute on the secondary store' do + expect(secondary_store).not_to receive(name) + + subject + end + + include_examples 'reads correct value' + end + + context 'when reading from primary instance is raising an exception' do + before do + allow(primary_store).to receive(name).with(*args).and_raise(StandardError) + allow(Gitlab::ErrorTracking).to receive(:log_exception) + end + + it 'logs the exception' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError), + hash_including(extra: hash_including(:multi_store_error_message, instance_name: instance_name), + command_name: name)) + + subject + end + + include_examples 'fallback read from the secondary store' + end + + context 'when reading from primary instance return no value' do + before do + allow(primary_store).to receive(name).and_return(nil) + end + + include_examples 'fallback read from the secondary store' + end + + context 'when the command is executed within pipelined block' do + subject do + multi_store.pipelined do + multi_store.send(name, *args) + end + end + + it 'is executed only 1 time on primary instance' do + expect(primary_store).to receive(name).with(*args).once + + subject + end + end + + if params[:block] + subject do + multi_store.send(name, *args, &block) + end + + context 'when block is provided' do + it 'yields to the block' do + expect(primary_store).to receive(name).and_yield(value) + + subject + end + + include_examples 'reads correct value' + end + end + end + + context 'with feature flag :use_multi_store is disabled' do + before do + stub_feature_flags(use_multi_store: false) + end + + it_behaves_like 'secondary store' + end + + context 'with both primary and secondary store using same redis instance' do + let(:primary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) } + let(:secondary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) } + let(:multi_store) { described_class.new(primary_store, secondary_store, instance_name)} + + it_behaves_like 'secondary store' + end + end + end + end + + context 'with WRITE redis commands' do + let_it_be(:key1) { "redis:{1}:key_a" } + let_it_be(:key2) { "redis:{1}:key_b" } + let_it_be(:value1) { "redis_value1"} + let_it_be(:value2) { "redis_value2"} + let_it_be(:key1_value1) { [key1, value1] } + let_it_be(:key1_value2) { [key1, value2] } + let_it_be(:ttl) { 10 } + let_it_be(:key1_ttl_value1) { [key1, ttl, value1] } + let_it_be(:skey) { "redis:set:key" } + let_it_be(:svalues1) { [value2, value1] } + let_it_be(:svalues2) { [value1] } + let_it_be(:skey_value1) { [skey, value1] } + let_it_be(:skey_value2) { [skey, value2] } + + where(:case_name, :name, :args, :expected_value, :verification_name, :verification_args) do + 'execute :set command' | :set | ref(:key1_value1) | ref(:value1) | :get | ref(:key1) + 'execute :setnx command' | :setnx | ref(:key1_value2) | ref(:value1) | :get | ref(:key2) + 'execute :setex command' | :setex | ref(:key1_ttl_value1) | ref(:ttl) | :ttl | ref(:key1) + 'execute :sadd command' | :sadd | ref(:skey_value2) | ref(:svalues1) | :smembers | ref(:skey) + 'execute :srem command' | :srem | ref(:skey_value1) | [] | :smembers | ref(:skey) + 'execute :del command' | :del | ref(:key2) | nil | :get | ref(:key2) + 'execute :flushdb command' | :flushdb | nil | 0 | :dbsize | nil + end + + before do + primary_store.flushdb + secondary_store.flushdb + + primary_store.multi do |multi| + multi.set(key2, value1) + multi.sadd(skey, value1) + end + + secondary_store.multi do |multi| + multi.set(key2, value1) + multi.sadd(skey, value1) + end + end + + RSpec.shared_examples_for 'verify that store contains values' do |store| + it "#{store} redis store contains correct values", :aggregate_errors do + subject + + redis_store = multi_store.send(store) + + if expected_value.is_a?(Array) + # :smembers does not guarantee the order it will return the values + expect(redis_store.send(verification_name, *verification_args)).to match_array(expected_value) + else + expect(redis_store.send(verification_name, *verification_args)).to eq(expected_value) + end + end + end + + with_them do + describe "#{name}" do + let(:expected_args) {args || no_args } + + before do + allow(primary_store).to receive(name).and_call_original + allow(secondary_store).to receive(name).and_call_original + end + + context 'with feature flag :use_multi_store enabled' do + before do + stub_feature_flags(use_multi_store: true) + end + + context 'when executing on primary instance is successful' do + it 'executes on both primary and secondary redis store', :aggregate_errors do + expect(primary_store).to receive(name).with(*expected_args).and_call_original + expect(secondary_store).to receive(name).with(*expected_args).and_call_original + + subject + end + + include_examples 'verify that store contains values', :primary_store + include_examples 'verify that store contains values', :secondary_store + end + + context 'when executing on the primary instance is raising an exception' do + before do + allow(primary_store).to receive(name).with(*expected_args).and_raise(StandardError) + allow(Gitlab::ErrorTracking).to receive(:log_exception) + end + + it 'logs the exception and execute on secondary instance', :aggregate_errors do + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError), + hash_including(extra: hash_including(:multi_store_error_message), command_name: name)) + expect(secondary_store).to receive(name).with(*expected_args).and_call_original + + subject + end + + include_examples 'verify that store contains values', :secondary_store + end + + context 'when the command is executed within pipelined block' do + subject do + multi_store.pipelined do + multi_store.send(name, *args) + end + end + + it 'is executed only 1 time on each instance', :aggregate_errors do + expect(primary_store).to receive(name).with(*expected_args).once + expect(secondary_store).to receive(name).with(*expected_args).once + + subject + end + + include_examples 'verify that store contains values', :primary_store + include_examples 'verify that store contains values', :secondary_store + end + end + + context 'with feature flag :use_multi_store is disabled' do + before do + stub_feature_flags(use_multi_store: false) + end + + it 'executes only on the secondary redis store', :aggregate_errors do + expect(secondary_store).to receive(name).with(*expected_args) + expect(primary_store).not_to receive(name).with(*expected_args) + + subject + end + + include_examples 'verify that store contains values', :secondary_store + end + end + end + end + + context 'with unsupported command' do + before do + primary_store.flushdb + secondary_store.flushdb + end + + let_it_be(:key) { "redis:counter" } + + subject do + multi_store.incr(key) + end + + it 'executes method missing' do + expect(multi_store).to receive(:method_missing) + + subject + end + + it 'logs MethodMissingError' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(Gitlab::Redis::MultiStore::MethodMissingError), + hash_including(command_name: :incr, extra: hash_including(instance_name: instance_name))) + + subject + end + + it 'increments method missing counter' do + expect(multi_store).to receive(:increment_method_missing_count).with(:incr) + + subject + end + + it 'fallback and executes only on the secondary store', :aggregate_errors do + expect(secondary_store).to receive(:incr).with(key).and_call_original + expect(primary_store).not_to receive(:incr) + + subject + end + + it 'correct value is stored on the secondary store', :aggregate_errors do + subject + + expect(primary_store.get(key)).to be_nil + expect(secondary_store.get(key)).to eq('1') + end + + context 'when the command is executed within pipelined block' do + subject do + multi_store.pipelined do + multi_store.incr(key) + end + end + + it 'is executed only 1 time on each instance', :aggregate_errors do + expect(primary_store).to receive(:incr).with(key).once + expect(secondary_store).to receive(:incr).with(key).once + + subject + end + + it "both redis stores are containing correct values", :aggregate_errors do + subject + + expect(primary_store.get(key)).to eq('1') + expect(secondary_store.get(key)).to eq('1') + end + end + end + + def create_redis_store(options, extras = {}) + ::Redis::Store.new(options.merge(extras)) + end +end diff --git a/spec/lib/gitlab/runtime_spec.rb b/spec/lib/gitlab/runtime_spec.rb index f51c5dd3d20..4627a8db82e 100644 --- a/spec/lib/gitlab/runtime_spec.rb +++ b/spec/lib/gitlab/runtime_spec.rb @@ -48,10 +48,9 @@ RSpec.describe Gitlab::Runtime do before do stub_const('::Puma', puma_type) - stub_env('ACTION_CABLE_IN_APP', 'false') end - it_behaves_like "valid runtime", :puma, 1 + it_behaves_like "valid runtime", :puma, 1 + Gitlab::ActionCable::Config.worker_pool_size end context "puma with cli_config" do @@ -61,27 +60,16 @@ RSpec.describe Gitlab::Runtime do before do stub_const('::Puma', puma_type) allow(puma_type).to receive_message_chain(:cli_config, :options).and_return(max_threads: 2, workers: max_workers) - stub_env('ACTION_CABLE_IN_APP', 'false') end - it_behaves_like "valid runtime", :puma, 3 + it_behaves_like "valid runtime", :puma, 3 + Gitlab::ActionCable::Config.worker_pool_size - context "when ActionCable in-app mode is enabled" do + context "when ActionCable worker pool size is configured" do before do - stub_env('ACTION_CABLE_IN_APP', 'true') - stub_env('ACTION_CABLE_WORKER_POOL_SIZE', '3') + stub_env('ACTION_CABLE_WORKER_POOL_SIZE', 10) end - it_behaves_like "valid runtime", :puma, 6 - end - - context "when ActionCable standalone is run" do - before do - stub_const('ACTION_CABLE_SERVER', true) - stub_env('ACTION_CABLE_WORKER_POOL_SIZE', '8') - end - - it_behaves_like "valid runtime", :puma, 11 + it_behaves_like "valid runtime", :puma, 13 end describe ".puma_in_clustered_mode?" do @@ -108,7 +96,7 @@ RSpec.describe Gitlab::Runtime do allow(sidekiq_type).to receive(:options).and_return(concurrency: 2) end - it_behaves_like "valid runtime", :sidekiq, 4 + it_behaves_like "valid runtime", :sidekiq, 5 end context "console" do diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 27d65e14347..a38073e7c51 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -96,6 +96,18 @@ RSpec.describe Gitlab::SearchResults do end end + describe '#aggregations' do + where(:scope) do + %w(projects issues merge_requests blobs commits wiki_blobs epics milestones users unknown) + end + + with_them do + it 'returns an empty array' do + expect(results.aggregations(scope)).to match_array([]) + end + end + end + context "when count_limit is lower than total amount" do before do allow(results).to receive(:count_limit).and_return(1) diff --git a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb deleted file mode 100644 index e818b03cf75..00000000000 --- a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb +++ /dev/null @@ -1,334 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' -require 'rspec-parameterized' - -RSpec.describe Gitlab::SidekiqCluster::CLI do - let(:cli) { described_class.new('/dev/null') } - let(:timeout) { described_class::DEFAULT_SOFT_TIMEOUT_SECONDS } - let(:default_options) do - { env: 'test', directory: Dir.pwd, max_concurrency: 50, min_concurrency: 0, dryrun: false, timeout: timeout } - end - - before do - stub_env('RAILS_ENV', 'test') - end - - describe '#run' do - context 'without any arguments' do - it 'raises CommandError' do - expect { cli.run([]) }.to raise_error(described_class::CommandError) - end - end - - context 'with arguments' do - before do - allow(cli).to receive(:write_pid) - allow(cli).to receive(:trap_signals) - allow(cli).to receive(:start_loop) - end - - it 'starts the Sidekiq workers' do - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([['foo']], default_options) - .and_return([]) - - cli.run(%w(foo)) - end - - it 'allows the special * selector' do - worker_queues = %w(foo bar baz) - - expect(Gitlab::SidekiqConfig::CliMethods) - .to receive(:worker_queues).and_return(worker_queues) - - expect(Gitlab::SidekiqCluster) - .to receive(:start).with([worker_queues], default_options) - - cli.run(%w(*)) - end - - it 'raises an error when the arguments contain newlines' do - invalid_arguments = [ - ["foo\n"], - ["foo\r"], - %W[foo b\nar] - ] - - invalid_arguments.each do |arguments| - expect { cli.run(arguments) }.to raise_error(described_class::CommandError) - end - end - - context 'with --negate flag' do - it 'starts Sidekiq workers for all queues in all_queues.yml except the ones in argv' do - expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(['baz']) - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([['baz']], default_options) - .and_return([]) - - cli.run(%w(foo -n)) - end - end - - context 'with --max-concurrency flag' do - it 'starts Sidekiq workers for specified queues with a max concurrency' do - expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(%w(foo bar baz)) - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([%w(foo bar baz), %w(solo)], default_options.merge(max_concurrency: 2)) - .and_return([]) - - cli.run(%w(foo,bar,baz solo -m 2)) - end - end - - context 'with --min-concurrency flag' do - it 'starts Sidekiq workers for specified queues with a min concurrency' do - expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(%w(foo bar baz)) - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([%w(foo bar baz), %w(solo)], default_options.merge(min_concurrency: 2)) - .and_return([]) - - cli.run(%w(foo,bar,baz solo --min-concurrency 2)) - end - end - - context 'with --timeout flag' do - it 'when given', 'starts Sidekiq workers with given timeout' do - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([['foo']], default_options.merge(timeout: 10)) - - cli.run(%w(foo --timeout 10)) - end - - it 'when not given', 'starts Sidekiq workers with default timeout' do - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([['foo']], default_options.merge(timeout: described_class::DEFAULT_SOFT_TIMEOUT_SECONDS)) - - cli.run(%w(foo)) - end - end - - context 'with --list-queues flag' do - it 'errors when given --list-queues and --dryrun' do - expect { cli.run(%w(foo --list-queues --dryrun)) }.to raise_error(described_class::CommandError) - end - - it 'prints out a list of queues in alphabetical order' do - expected_queues = [ - 'epics:epics_update_epics_dates', - 'epics_new_epic_issue', - 'new_epic', - 'todos_destroyer:todos_destroyer_confidential_epic' - ] - - allow(Gitlab::SidekiqConfig::CliMethods).to receive(:query_queues).and_return(expected_queues.shuffle) - - expect(cli).to receive(:puts).with([expected_queues]) - - cli.run(%w(--queue-selector feature_category=epics --list-queues)) - end - end - - context 'queue namespace expansion' do - it 'starts Sidekiq workers for all queues in all_queues.yml with a namespace in argv' do - expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(['cronjob:foo', 'cronjob:bar']) - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([['cronjob', 'cronjob:foo', 'cronjob:bar']], default_options) - .and_return([]) - - cli.run(%w(cronjob)) - end - end - - context "with --queue-selector" do - where do - { - 'memory-bound queues' => { - query: 'resource_boundary=memory', - included_queues: %w(project_export), - excluded_queues: %w(merge) - }, - 'memory- or CPU-bound queues' => { - query: 'resource_boundary=memory,cpu', - included_queues: %w(auto_merge:auto_merge_process project_export), - excluded_queues: %w(merge) - }, - 'high urgency CI queues' => { - query: 'feature_category=continuous_integration&urgency=high', - included_queues: %w(pipeline_cache:expire_job_cache pipeline_cache:expire_pipeline_cache), - excluded_queues: %w(merge) - }, - 'CPU-bound high urgency CI queues' => { - query: 'feature_category=continuous_integration&urgency=high&resource_boundary=cpu', - included_queues: %w(pipeline_cache:expire_pipeline_cache), - excluded_queues: %w(pipeline_cache:expire_job_cache merge) - }, - 'CPU-bound high urgency non-CI queues' => { - query: 'feature_category!=continuous_integration&urgency=high&resource_boundary=cpu', - included_queues: %w(new_issue), - excluded_queues: %w(pipeline_cache:expire_pipeline_cache) - }, - 'CI and SCM queues' => { - query: 'feature_category=continuous_integration|feature_category=source_code_management', - included_queues: %w(pipeline_cache:expire_job_cache merge), - excluded_queues: %w(mailers) - } - } - end - - with_them do - it 'expands queues by attributes' do - expect(Gitlab::SidekiqCluster).to receive(:start) do |queues, opts| - expect(opts).to eq(default_options) - expect(queues.first).to include(*included_queues) - expect(queues.first).not_to include(*excluded_queues) - - [] - end - - cli.run(%W(--queue-selector #{query})) - end - - it 'works when negated' do - expect(Gitlab::SidekiqCluster).to receive(:start) do |queues, opts| - expect(opts).to eq(default_options) - expect(queues.first).not_to include(*included_queues) - expect(queues.first).to include(*excluded_queues) - - [] - end - - cli.run(%W(--negate --queue-selector #{query})) - end - end - - it 'expands multiple queue groups correctly' do - expect(Gitlab::SidekiqCluster) - .to receive(:start) - .with([['chat_notification'], ['project_export']], default_options) - .and_return([]) - - cli.run(%w(--queue-selector feature_category=chatops&has_external_dependencies=true resource_boundary=memory&feature_category=importers)) - end - - it 'allows the special * selector' do - worker_queues = %w(foo bar baz) - - expect(Gitlab::SidekiqConfig::CliMethods) - .to receive(:worker_queues).and_return(worker_queues) - - expect(Gitlab::SidekiqCluster) - .to receive(:start).with([worker_queues], default_options) - - cli.run(%w(--queue-selector *)) - end - - it 'errors when the selector matches no queues' do - expect(Gitlab::SidekiqCluster).not_to receive(:start) - - expect { cli.run(%w(--queue-selector has_external_dependencies=true&has_external_dependencies=false)) } - .to raise_error(described_class::CommandError) - end - - it 'errors on an invalid query multiple queue groups correctly' do - expect(Gitlab::SidekiqCluster).not_to receive(:start) - - expect { cli.run(%w(--queue-selector unknown_field=chatops)) } - .to raise_error(Gitlab::SidekiqConfig::WorkerMatcher::QueryError) - end - end - end - end - - describe '#write_pid' do - context 'when a PID is specified' do - it 'writes the PID to a file' do - expect(Gitlab::SidekiqCluster).to receive(:write_pid).with('/dev/null') - - cli.option_parser.parse!(%w(-P /dev/null)) - cli.write_pid - end - end - - context 'when no PID is specified' do - it 'does not write a PID' do - expect(Gitlab::SidekiqCluster).not_to receive(:write_pid) - - cli.write_pid - end - end - end - - describe '#wait_for_termination' do - it 'waits for termination of all sub-processes and succeeds after 3 checks' do - expect(Gitlab::SidekiqCluster).to receive(:any_alive?) - .with(an_instance_of(Array)).and_return(true, true, true, false) - - expect(Gitlab::SidekiqCluster).to receive(:pids_alive) - .with([]).and_return([]) - - expect(Gitlab::SidekiqCluster).to receive(:signal_processes) - .with([], "-KILL") - - stub_const("Gitlab::SidekiqCluster::CLI::CHECK_TERMINATE_INTERVAL_SECONDS", 0.1) - allow(cli).to receive(:terminate_timeout_seconds) { 1 } - - cli.wait_for_termination - end - - context 'with hanging workers' do - before do - expect(cli).to receive(:write_pid) - expect(cli).to receive(:trap_signals) - expect(cli).to receive(:start_loop) - end - - it 'hard kills workers after timeout expires' do - worker_pids = [101, 102, 103] - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([['foo']], default_options) - .and_return(worker_pids) - - expect(Gitlab::SidekiqCluster).to receive(:any_alive?) - .with(worker_pids).and_return(true).at_least(10).times - - expect(Gitlab::SidekiqCluster).to receive(:pids_alive) - .with(worker_pids).and_return([102]) - - expect(Gitlab::SidekiqCluster).to receive(:signal_processes) - .with([102], "-KILL") - - cli.run(%w(foo)) - - stub_const("Gitlab::SidekiqCluster::CLI::CHECK_TERMINATE_INTERVAL_SECONDS", 0.1) - allow(cli).to receive(:terminate_timeout_seconds) { 1 } - - cli.wait_for_termination - end - end - end - - describe '#trap_signals' do - it 'traps the termination and forwarding signals' do - expect(Gitlab::SidekiqCluster).to receive(:trap_terminate) - expect(Gitlab::SidekiqCluster).to receive(:trap_forward) - - cli.trap_signals - end - end - - describe '#start_loop' do - it 'runs until one of the processes has been terminated' do - allow(cli).to receive(:sleep).with(a_kind_of(Numeric)) - - expect(Gitlab::SidekiqCluster).to receive(:all_alive?) - .with(an_instance_of(Array)).and_return(false) - - expect(Gitlab::SidekiqCluster).to receive(:signal_processes) - .with(an_instance_of(Array), :TERM) - - cli.start_loop - end - end -end diff --git a/spec/lib/gitlab/sidekiq_cluster_spec.rb b/spec/lib/gitlab/sidekiq_cluster_spec.rb deleted file mode 100644 index 3c6ea054968..00000000000 --- a/spec/lib/gitlab/sidekiq_cluster_spec.rb +++ /dev/null @@ -1,207 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' -require 'rspec-parameterized' - -RSpec.describe Gitlab::SidekiqCluster do - describe '.trap_signals' do - it 'traps the given signals' do - expect(described_class).to receive(:trap).ordered.with(:INT) - expect(described_class).to receive(:trap).ordered.with(:HUP) - - described_class.trap_signals(%i(INT HUP)) - end - end - - describe '.trap_terminate' do - it 'traps the termination signals' do - expect(described_class).to receive(:trap_signals) - .with(described_class::TERMINATE_SIGNALS) - - described_class.trap_terminate { } - end - end - - describe '.trap_forward' do - it 'traps the signals to forward' do - expect(described_class).to receive(:trap_signals) - .with(described_class::FORWARD_SIGNALS) - - described_class.trap_forward { } - end - end - - describe '.signal' do - it 'sends a signal to the given process' do - allow(Process).to receive(:kill).with(:INT, 4) - expect(described_class.signal(4, :INT)).to eq(true) - end - - it 'returns false when the process does not exist' do - allow(Process).to receive(:kill).with(:INT, 4).and_raise(Errno::ESRCH) - expect(described_class.signal(4, :INT)).to eq(false) - end - end - - describe '.signal_processes' do - it 'sends a signal to every given process' do - expect(described_class).to receive(:signal).with(1, :INT) - - described_class.signal_processes([1], :INT) - end - end - - describe '.start' do - it 'starts Sidekiq with the given queues, environment and options' do - expected_options = { - env: :production, - directory: 'foo/bar', - max_concurrency: 20, - min_concurrency: 10, - timeout: 25, - dryrun: true - } - - expect(described_class).to receive(:start_sidekiq).ordered.with(%w(foo), expected_options.merge(worker_id: 0)) - expect(described_class).to receive(:start_sidekiq).ordered.with(%w(bar baz), expected_options.merge(worker_id: 1)) - - described_class.start([%w(foo), %w(bar baz)], env: :production, directory: 'foo/bar', max_concurrency: 20, min_concurrency: 10, dryrun: true) - end - - it 'starts Sidekiq with the given queues and sensible default options' do - expected_options = { - env: :development, - directory: an_instance_of(String), - max_concurrency: 50, - min_concurrency: 0, - worker_id: an_instance_of(Integer), - timeout: 25, - dryrun: false - } - - expect(described_class).to receive(:start_sidekiq).ordered.with(%w(foo bar baz), expected_options) - expect(described_class).to receive(:start_sidekiq).ordered.with(%w(solo), expected_options) - - described_class.start([%w(foo bar baz), %w(solo)]) - end - end - - describe '.start_sidekiq' do - let(:first_worker_id) { 0 } - let(:options) do - { env: :production, directory: 'foo/bar', max_concurrency: 20, min_concurrency: 0, worker_id: first_worker_id, timeout: 10, dryrun: false } - end - - let(:env) { { "ENABLE_SIDEKIQ_CLUSTER" => "1", "SIDEKIQ_WORKER_ID" => first_worker_id.to_s } } - let(:args) { ['bundle', 'exec', 'sidekiq', anything, '-eproduction', '-t10', *([anything] * 5)] } - - it 'starts a Sidekiq process' do - allow(Process).to receive(:spawn).and_return(1) - - expect(described_class).to receive(:wait_async).with(1) - expect(described_class.start_sidekiq(%w(foo), **options)).to eq(1) - end - - it 'handles duplicate queue names' do - allow(Process) - .to receive(:spawn) - .with(env, *args, anything) - .and_return(1) - - expect(described_class).to receive(:wait_async).with(1) - expect(described_class.start_sidekiq(%w(foo foo bar baz), **options)).to eq(1) - end - - it 'runs the sidekiq process in a new process group' do - expect(Process) - .to receive(:spawn) - .with(anything, *args, a_hash_including(pgroup: true)) - .and_return(1) - - allow(described_class).to receive(:wait_async) - expect(described_class.start_sidekiq(%w(foo bar baz), **options)).to eq(1) - end - end - - describe '.count_by_queue' do - it 'tallies the queue counts' do - queues = [%w(foo), %w(bar baz), %w(foo)] - - expect(described_class.count_by_queue(queues)).to eq(%w(foo) => 2, %w(bar baz) => 1) - end - end - - describe '.concurrency' do - using RSpec::Parameterized::TableSyntax - - where(:queue_count, :min, :max, :expected) do - 2 | 0 | 0 | 3 # No min or max specified - 2 | 0 | 9 | 3 # No min specified, value < max - 2 | 1 | 4 | 3 # Value between min and max - 2 | 4 | 5 | 4 # Value below range - 5 | 2 | 3 | 3 # Value above range - 2 | 1 | 1 | 1 # Value above explicit setting (min == max) - 0 | 3 | 3 | 3 # Value below explicit setting (min == max) - 1 | 4 | 3 | 3 # Min greater than max - end - - with_them do - let(:queues) { Array.new(queue_count) } - - it { expect(described_class.concurrency(queues, min, max)).to eq(expected) } - end - end - - describe '.wait_async' do - it 'waits for a process in a separate thread' do - thread = described_class.wait_async(Process.spawn('true')) - - # Upon success Process.wait just returns the PID. - expect(thread.value).to be_a_kind_of(Numeric) - end - end - - # In the X_alive? checks, we check negative PIDs sometimes as a simple way - # to be sure the pids are definitely for non-existent processes. - # Note that -1 is special, and sends the signal to every process we have permission - # for, so we use -2, -3 etc - describe '.all_alive?' do - it 'returns true if all processes are alive' do - processes = [Process.pid] - - expect(described_class.all_alive?(processes)).to eq(true) - end - - it 'returns false when a thread was not alive' do - processes = [-2] - - expect(described_class.all_alive?(processes)).to eq(false) - end - end - - describe '.any_alive?' do - it 'returns true if at least one process is alive' do - processes = [Process.pid, -2] - - expect(described_class.any_alive?(processes)).to eq(true) - end - - it 'returns false when all threads are dead' do - processes = [-2, -3] - - expect(described_class.any_alive?(processes)).to eq(false) - end - end - - describe '.write_pid' do - it 'writes the PID of the current process to the given file' do - handle = double(:handle) - - allow(File).to receive(:open).with('/dev/null', 'w').and_yield(handle) - - expect(handle).to receive(:write).with(Process.pid.to_s) - - described_class.write_pid('/dev/null') - end - end -end diff --git a/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb b/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb index bc63289a344..576b36c1829 100644 --- a/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb +++ b/spec/lib/gitlab/sidekiq_config/cli_methods_spec.rb @@ -11,12 +11,12 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do end def stub_exists(exists: true) - ['app/workers/all_queues.yml', 'ee/app/workers/all_queues.yml'].each do |path| + ['app/workers/all_queues.yml', 'ee/app/workers/all_queues.yml', 'jh/app/workers/all_queues.yml'].each do |path| allow(File).to receive(:exist?).with(expand_path(path)).and_return(exists) end end - def stub_contents(foss_queues, ee_queues) + def stub_contents(foss_queues, ee_queues, jh_queues) allow(YAML).to receive(:load_file) .with(expand_path('app/workers/all_queues.yml')) .and_return(foss_queues) @@ -24,6 +24,10 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do allow(YAML).to receive(:load_file) .with(expand_path('ee/app/workers/all_queues.yml')) .and_return(ee_queues) + + allow(YAML).to receive(:load_file) + .with(expand_path('jh/app/workers/all_queues.yml')) + .and_return(jh_queues) end before do @@ -45,8 +49,9 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do end it 'flattens and joins the contents' do - expected_queues = %w[queue_a queue_b] - expected_queues = expected_queues.first(1) unless Gitlab.ee? + expected_queues = %w[queue_a] + expected_queues << 'queue_b' if Gitlab.ee? + expected_queues << 'queue_c' if Gitlab.jh? expect(described_class.worker_queues(dummy_root)) .to match_array(expected_queues) @@ -55,7 +60,7 @@ RSpec.describe Gitlab::SidekiqConfig::CliMethods do context 'when the file contains an array of hashes' do before do - stub_contents([{ name: 'queue_a' }], [{ name: 'queue_b' }]) + stub_contents([{ name: 'queue_a' }], [{ name: 'queue_b' }], [{ name: 'queue_c' }]) end include_examples 'valid file contents' diff --git a/spec/lib/gitlab/sidekiq_config/worker_spec.rb b/spec/lib/gitlab/sidekiq_config/worker_spec.rb index f4d7a4b3359..9c252b3d50b 100644 --- a/spec/lib/gitlab/sidekiq_config/worker_spec.rb +++ b/spec/lib/gitlab/sidekiq_config/worker_spec.rb @@ -18,19 +18,26 @@ RSpec.describe Gitlab::SidekiqConfig::Worker do get_tags: attributes[:tags] ) - described_class.new(inner_worker, ee: false) + described_class.new(inner_worker, ee: false, jh: false) end describe '#ee?' do it 'returns the EE status set on creation' do - expect(described_class.new(double, ee: true)).to be_ee - expect(described_class.new(double, ee: false)).not_to be_ee + expect(described_class.new(double, ee: true, jh: false)).to be_ee + expect(described_class.new(double, ee: false, jh: false)).not_to be_ee + end + end + + describe '#jh?' do + it 'returns the JH status set on creation' do + expect(described_class.new(double, ee: false, jh: true)).to be_jh + expect(described_class.new(double, ee: false, jh: false)).not_to be_jh end end describe '#==' do def worker_with_yaml(yaml) - described_class.new(double, ee: false).tap do |worker| + described_class.new(double, ee: false, jh: false).tap do |worker| allow(worker).to receive(:to_yaml).and_return(yaml) end end @@ -57,7 +64,7 @@ RSpec.describe Gitlab::SidekiqConfig::Worker do expect(worker).to receive(meth) - described_class.new(worker, ee: false).send(meth) + described_class.new(worker, ee: false, jh: false).send(meth) end end end diff --git a/spec/lib/gitlab/sidekiq_enq_spec.rb b/spec/lib/gitlab/sidekiq_enq_spec.rb new file mode 100644 index 00000000000..6903f01bf5f --- /dev/null +++ b/spec/lib/gitlab/sidekiq_enq_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::SidekiqEnq, :clean_gitlab_redis_queues do + let(:retry_set) { Sidekiq::Scheduled::SETS.first } + let(:schedule_set) { Sidekiq::Scheduled::SETS.last } + + around do |example| + freeze_time { example.run } + end + + shared_examples 'finds jobs that are due and enqueues them' do + before do + Sidekiq.redis do |redis| + redis.zadd(retry_set, (Time.current - 1.day).to_f.to_s, '{"jid": 1}') + redis.zadd(retry_set, Time.current.to_f.to_s, '{"jid": 2}') + redis.zadd(retry_set, (Time.current + 1.day).to_f.to_s, '{"jid": 3}') + + redis.zadd(schedule_set, (Time.current - 1.day).to_f.to_s, '{"jid": 4}') + redis.zadd(schedule_set, Time.current.to_f.to_s, '{"jid": 5}') + redis.zadd(schedule_set, (Time.current + 1.day).to_f.to_s, '{"jid": 6}') + end + end + + it 'enqueues jobs that are due' do + expect(Sidekiq::Client).to receive(:push).with({ 'jid' => 1 }) + expect(Sidekiq::Client).to receive(:push).with({ 'jid' => 2 }) + expect(Sidekiq::Client).to receive(:push).with({ 'jid' => 4 }) + expect(Sidekiq::Client).to receive(:push).with({ 'jid' => 5 }) + + Gitlab::SidekiqEnq.new.enqueue_jobs + + Sidekiq.redis do |redis| + expect(redis.zscan_each(retry_set).map(&:first)).to contain_exactly('{"jid": 3}') + expect(redis.zscan_each(schedule_set).map(&:first)).to contain_exactly('{"jid": 6}') + end + end + end + + context 'when atomic_sidekiq_scheduler is disabled' do + before do + stub_feature_flags(atomic_sidekiq_scheduler: false) + end + + it_behaves_like 'finds jobs that are due and enqueues them' + + context 'when ZRANGEBYSCORE returns a job that is already removed by another process' do + before do + Sidekiq.redis do |redis| + redis.zadd(schedule_set, Time.current.to_f.to_s, '{"jid": 1}') + + allow(redis).to receive(:zrangebyscore).and_wrap_original do |m, *args, **kwargs| + m.call(*args, **kwargs).tap do |jobs| + redis.zrem(schedule_set, jobs.first) if args[0] == schedule_set && jobs.first + end + end + end + end + + it 'calls ZREM but does not enqueue the job' do + Sidekiq.redis do |redis| + expect(redis).to receive(:zrem).with(schedule_set, '{"jid": 1}').twice.and_call_original + end + expect(Sidekiq::Client).not_to receive(:push) + + Gitlab::SidekiqEnq.new.enqueue_jobs + end + end + end + + context 'when atomic_sidekiq_scheduler is enabled' do + before do + stub_feature_flags(atomic_sidekiq_scheduler: true) + end + + context 'when Lua script is not yet loaded' do + before do + Gitlab::Redis::Queues.with { |redis| redis.script(:flush) } + end + + it_behaves_like 'finds jobs that are due and enqueues them' + end + + context 'when Lua script is already loaded' do + before do + Gitlab::SidekiqEnq.new.enqueue_jobs + end + + it_behaves_like 'finds jobs that are due and enqueues them' + end + end +end diff --git a/spec/lib/gitlab/sidekiq_logging/deduplication_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/deduplication_logger_spec.rb index 82f927fe481..f44a1e8b6ba 100644 --- a/spec/lib/gitlab/sidekiq_logging/deduplication_logger_spec.rb +++ b/spec/lib/gitlab/sidekiq_logging/deduplication_logger_spec.rb @@ -23,11 +23,37 @@ RSpec.describe Gitlab::SidekiqLogging::DeduplicationLogger do } expect(Sidekiq.logger).to receive(:info).with(a_hash_including(expected_payload)).and_call_original - described_class.instance.log(job, "a fancy strategy", { foo: :bar }) + described_class.instance.deduplicated_log(job, "a fancy strategy", { foo: :bar }) end it "does not modify the job" do - expect { described_class.instance.log(job, "a fancy strategy") } + expect { described_class.instance.deduplicated_log(job, "a fancy strategy") } + .not_to change { job } + end + end + + describe '#rescheduled_log' do + let(:job) do + { + 'class' => 'TestWorker', + 'args' => [1234, 'hello', { 'key' => 'value' }], + 'jid' => 'da883554ee4fe414012f5f42', + 'correlation_id' => 'cid' + } + end + + it 'logs a rescheduled message to the sidekiq logger' do + expected_payload = { + 'job_status' => 'rescheduled', + 'message' => "#{job['class']} JID-#{job['jid']}: rescheduled" + } + expect(Sidekiq.logger).to receive(:info).with(a_hash_including(expected_payload)).and_call_original + + described_class.instance.rescheduled_log(job) + end + + it 'does not modify the job' do + expect { described_class.instance.rescheduled_log(job) } .not_to change { job } end end diff --git a/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb b/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb index c879fdea3ad..b6fb3fecf20 100644 --- a/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb +++ b/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb @@ -17,6 +17,7 @@ RSpec.describe Gitlab::SidekiqLogging::JSONFormatter do 'class' => 'PostReceive', 'bar' => 'test', 'created_at' => timestamp, + 'scheduled_at' => timestamp, 'enqueued_at' => timestamp, 'started_at' => timestamp, 'retried_at' => timestamp, @@ -31,6 +32,7 @@ RSpec.describe Gitlab::SidekiqLogging::JSONFormatter do 'severity' => 'INFO', 'time' => timestamp_iso8601, 'created_at' => timestamp_iso8601, + 'scheduled_at' => timestamp_iso8601, 'enqueued_at' => timestamp_iso8601, 'started_at' => timestamp_iso8601, 'retried_at' => timestamp_iso8601, diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb index 5083ac514db..833de6ae624 100644 --- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb @@ -24,6 +24,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi "#{Gitlab::Redis::Queues::SIDEKIQ_NAMESPACE}:duplicate:#{queue}:#{hash}" end + let(:deduplicated_flag_key) do + "#{idempotency_key}:deduplicate_flag" + end + describe '#schedule' do shared_examples 'scheduling with deduplication class' do |strategy_class| it 'calls schedule on the strategy' do @@ -81,25 +85,43 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi context 'when there was no job in the queue yet' do it { expect(duplicate_job.check!).to eq('123') } - it "adds a idempotency key with ttl set to #{described_class::DUPLICATE_KEY_TTL}" do - expect { duplicate_job.check! } - .to change { read_idempotency_key_with_ttl(idempotency_key) } - .from([nil, -2]) - .to(['123', be_within(1).of(described_class::DUPLICATE_KEY_TTL)]) - end - - context 'when wal locations is not empty' do - it "adds a existing wal locations key with ttl set to #{described_class::DUPLICATE_KEY_TTL}" do + shared_examples 'sets Redis keys with correct TTL' do + it "adds an idempotency key with correct ttl" do expect { duplicate_job.check! } - .to change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :main)) } - .from([nil, -2]) - .to([wal_locations[:main], be_within(1).of(described_class::DUPLICATE_KEY_TTL)]) - .and change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :ci)) } + .to change { read_idempotency_key_with_ttl(idempotency_key) } .from([nil, -2]) - .to([wal_locations[:ci], be_within(1).of(described_class::DUPLICATE_KEY_TTL)]) + .to(['123', be_within(1).of(expected_ttl)]) + end + + context 'when wal locations is not empty' do + it "adds an existing wal locations key with correct ttl" do + expect { duplicate_job.check! } + .to change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :main)) } + .from([nil, -2]) + .to([wal_locations[:main], be_within(1).of(expected_ttl)]) + .and change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :ci)) } + .from([nil, -2]) + .to([wal_locations[:ci], be_within(1).of(expected_ttl)]) + end end end + context 'with TTL option is not set' do + let(:expected_ttl) { described_class::DEFAULT_DUPLICATE_KEY_TTL } + + it_behaves_like 'sets Redis keys with correct TTL' + end + + context 'when TTL option is set' do + let(:expected_ttl) { 5.minutes } + + before do + allow(duplicate_job).to receive(:options).and_return({ ttl: expected_ttl }) + end + + it_behaves_like 'sets Redis keys with correct TTL' + end + context 'when preserve_latest_wal_locations_for_idempotent_jobs feature flag is disabled' do before do stub_feature_flags(preserve_latest_wal_locations_for_idempotent_jobs: false) @@ -152,26 +174,21 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi end describe '#update_latest_wal_location!' do - let(:offset) { '1024' } - before do - allow(duplicate_job).to receive(:pg_wal_lsn_diff).with(:main).and_return(offset) - allow(duplicate_job).to receive(:pg_wal_lsn_diff).with(:ci).and_return(offset) - end + allow(Gitlab::Database).to receive(:database_base_models).and_return( + { main: ::ActiveRecord::Base, + ci: ::ActiveRecord::Base }) - shared_examples 'updates wal location' do - it 'updates a wal location to redis with an offset' do - expect { duplicate_job.update_latest_wal_location! } - .to change { read_range_from_redis(wal_location_key(idempotency_key, :main)) } - .from(existing_wal_with_offset[:main]) - .to(new_wal_with_offset[:main]) - .and change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) } - .from(existing_wal_with_offset[:ci]) - .to(new_wal_with_offset[:ci]) - end + set_idempotency_key(existing_wal_location_key(idempotency_key, :main), existing_wal[:main]) + set_idempotency_key(existing_wal_location_key(idempotency_key, :ci), existing_wal[:ci]) + + # read existing_wal_locations + duplicate_job.check! end context 'when preserve_latest_wal_locations_for_idempotent_jobs feature flag is disabled' do + let(:existing_wal) { {} } + before do stub_feature_flags(preserve_latest_wal_locations_for_idempotent_jobs: false) end @@ -192,42 +209,107 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi end context "when the key doesn't exists in redis" do - include_examples 'updates wal location' do - let(:existing_wal_with_offset) { { main: [], ci: [] } } - let(:new_wal_with_offset) { wal_locations.transform_values { |v| [v, offset] } } + let(:existing_wal) do + { + main: '0/D525E3A0', + ci: 'AB/12340' + } end - end - context "when the key exists in redis" do - let(:existing_offset) { '1023'} - let(:existing_wal_locations) do + let(:new_wal_location_with_offset) do { - main: '0/D525E3NM', - ci: 'AB/111112' + # offset is relative to `existing_wal` + main: ['0/D525E3A8', '8'], + ci: ['AB/12345', '5'] } end + let(:wal_locations) { new_wal_location_with_offset.transform_values(&:first) } + + it 'stores a wal location to redis with an offset relative to existing wal location' do + expect { duplicate_job.update_latest_wal_location! } + .to change { read_range_from_redis(wal_location_key(idempotency_key, :main)) } + .from([]) + .to(new_wal_location_with_offset[:main]) + .and change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) } + .from([]) + .to(new_wal_location_with_offset[:ci]) + end + end + + context "when the key exists in redis" do before do - rpush_to_redis_key(wal_location_key(idempotency_key, :main), existing_wal_locations[:main], existing_offset) - rpush_to_redis_key(wal_location_key(idempotency_key, :ci), existing_wal_locations[:ci], existing_offset) + rpush_to_redis_key(wal_location_key(idempotency_key, :main), *stored_wal_location_with_offset[:main]) + rpush_to_redis_key(wal_location_key(idempotency_key, :ci), *stored_wal_location_with_offset[:ci]) end + let(:wal_locations) { new_wal_location_with_offset.transform_values(&:first) } + context "when the new offset is bigger then the existing one" do - include_examples 'updates wal location' do - let(:existing_wal_with_offset) { existing_wal_locations.transform_values { |v| [v, existing_offset] } } - let(:new_wal_with_offset) { wal_locations.transform_values { |v| [v, offset] } } + let(:existing_wal) do + { + main: '0/D525E3A0', + ci: 'AB/12340' + } + end + + let(:stored_wal_location_with_offset) do + { + # offset is relative to `existing_wal` + main: ['0/D525E3A3', '3'], + ci: ['AB/12342', '2'] + } + end + + let(:new_wal_location_with_offset) do + { + # offset is relative to `existing_wal` + main: ['0/D525E3A8', '8'], + ci: ['AB/12345', '5'] + } + end + + it 'updates a wal location to redis with an offset' do + expect { duplicate_job.update_latest_wal_location! } + .to change { read_range_from_redis(wal_location_key(idempotency_key, :main)) } + .from(stored_wal_location_with_offset[:main]) + .to(new_wal_location_with_offset[:main]) + .and change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) } + .from(stored_wal_location_with_offset[:ci]) + .to(new_wal_location_with_offset[:ci]) end end context "when the old offset is not bigger then the existing one" do - let(:existing_offset) { offset } + let(:existing_wal) do + { + main: '0/D525E3A0', + ci: 'AB/12340' + } + end + + let(:stored_wal_location_with_offset) do + { + # offset is relative to `existing_wal` + main: ['0/D525E3A8', '8'], + ci: ['AB/12345', '5'] + } + end + + let(:new_wal_location_with_offset) do + { + # offset is relative to `existing_wal` + main: ['0/D525E3A2', '2'], + ci: ['AB/12342', '2'] + } + end it "does not update a wal location to redis with an offset" do expect { duplicate_job.update_latest_wal_location! } .to not_change { read_range_from_redis(wal_location_key(idempotency_key, :main)) } - .from([existing_wal_locations[:main], existing_offset]) + .from(stored_wal_location_with_offset[:main]) .and not_change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) } - .from([existing_wal_locations[:ci], existing_offset]) + .from(stored_wal_location_with_offset[:ci]) end end end @@ -270,6 +352,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi context 'when the key exists in redis' do before do set_idempotency_key(idempotency_key, 'existing-jid') + set_idempotency_key(deduplicated_flag_key, 1) wal_locations.each do |config_name, location| set_idempotency_key(existing_wal_location_key(idempotency_key, config_name), location) set_idempotency_key(wal_location_key(idempotency_key, config_name), location) @@ -299,6 +382,11 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi let(:from_value) { 'existing-jid' } end + it_behaves_like 'deleting keys from redis', 'deduplication counter key' do + let(:key) { deduplicated_flag_key } + let(:from_value) { '1' } + end + it_behaves_like 'deleting keys from redis', 'existing wal location keys for main database' do let(:key) { existing_wal_location_key(idempotency_key, :main) } let(:from_value) { wal_locations[:main] } @@ -390,6 +478,103 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi end end + describe '#reschedule' do + it 'reschedules the current job' do + fake_logger = instance_double(Gitlab::SidekiqLogging::DeduplicationLogger) + expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger) + expect(fake_logger).to receive(:rescheduled_log).with(a_hash_including({ 'jid' => '123' })) + expect(AuthorizedProjectsWorker).to receive(:perform_async).with(1).once + + duplicate_job.reschedule + end + end + + describe '#should_reschedule?' do + subject { duplicate_job.should_reschedule? } + + context 'when the job is reschedulable' do + before do + allow(duplicate_job).to receive(:reschedulable?) { true } + end + + it { is_expected.to eq(false) } + + context 'with deduplicated flag' do + before do + duplicate_job.set_deduplicated_flag! + end + + it { is_expected.to eq(true) } + end + end + + context 'when the job is not reschedulable' do + before do + allow(duplicate_job).to receive(:reschedulable?) { false } + end + + it { is_expected.to eq(false) } + + context 'with deduplicated flag' do + before do + duplicate_job.set_deduplicated_flag! + end + + it { is_expected.to eq(false) } + end + end + end + + describe '#set_deduplicated_flag!' do + context 'when the job is reschedulable' do + before do + allow(duplicate_job).to receive(:reschedulable?) { true } + end + + it 'sets the key in Redis' do + duplicate_job.set_deduplicated_flag! + + flag = Sidekiq.redis { |redis| redis.get(deduplicated_flag_key) } + + expect(flag).to eq(described_class::DEDUPLICATED_FLAG_VALUE.to_s) + end + + it 'sets, gets and cleans up the deduplicated flag' do + expect(duplicate_job.should_reschedule?).to eq(false) + + duplicate_job.set_deduplicated_flag! + expect(duplicate_job.should_reschedule?).to eq(true) + + duplicate_job.delete! + expect(duplicate_job.should_reschedule?).to eq(false) + end + end + + context 'when the job is not reschedulable' do + before do + allow(duplicate_job).to receive(:reschedulable?) { false } + end + + it 'does not set the key in Redis' do + duplicate_job.set_deduplicated_flag! + + flag = Sidekiq.redis { |redis| redis.get(deduplicated_flag_key) } + + expect(flag).to be_nil + end + + it 'does not set the deduplicated flag' do + expect(duplicate_job.should_reschedule?).to eq(false) + + duplicate_job.set_deduplicated_flag! + expect(duplicate_job.should_reschedule?).to eq(false) + + duplicate_job.delete! + expect(duplicate_job.should_reschedule?).to eq(false) + end + end + end + describe '#duplicate?' do it "raises an error if the check wasn't performed" do expect { duplicate_job.duplicate? }.to raise_error /Call `#check!` first/ @@ -494,12 +679,12 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi end end - def existing_wal_location_key(idempotency_key, config_name) - "#{idempotency_key}:#{config_name}:existing_wal_location" + def existing_wal_location_key(idempotency_key, connection_name) + "#{idempotency_key}:#{connection_name}:existing_wal_location" end - def wal_location_key(idempotency_key, config_name) - "#{idempotency_key}:#{config_name}:wal_location" + def wal_location_key(idempotency_key, connection_name) + "#{idempotency_key}:#{connection_name}:wal_location" end def set_idempotency_key(key, value = '1') diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb index 9772255fc50..963301bc001 100644 --- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb @@ -9,6 +9,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecut before do allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( {} ) + allow(fake_duplicate_job).to receive(:scheduled?) { false } + allow(fake_duplicate_job).to receive(:options) { {} } + allow(fake_duplicate_job).to receive(:should_reschedule?) { false } end it 'deletes the lock after executing' do @@ -19,6 +22,28 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecut proc.call end end + + it 'does not reschedule the job even if deduplication happened' do + expect(fake_duplicate_job).to receive(:delete!) + expect(fake_duplicate_job).not_to receive(:reschedule) + + strategy.perform({}) do + proc.call + end + end + + context 'when job is reschedulable' do + it 'reschedules the job if deduplication happened' do + allow(fake_duplicate_job).to receive(:should_reschedule?) { true } + + expect(fake_duplicate_job).to receive(:delete!) + expect(fake_duplicate_job).to receive(:reschedule).once + + strategy.perform({}) do + proc.call + end + end + end end end end diff --git a/spec/lib/gitlab/sidekiq_middleware/query_analyzer_spec.rb b/spec/lib/gitlab/sidekiq_middleware/query_analyzer_spec.rb new file mode 100644 index 00000000000..e58af1d60fe --- /dev/null +++ b/spec/lib/gitlab/sidekiq_middleware/query_analyzer_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::SidekiqMiddleware::QueryAnalyzer, query_analyzers: false do + describe 'the PreventCrossDatabaseModification' do + describe '#call' do + let(:worker) { double(:worker) } + let(:job) { { 'jid' => 'job123' } } + let(:queue) { 'some-queue' } + let(:middleware) { described_class.new } + + def do_queries + end + + subject { middleware.call(worker, job, queue) { do_queries } } + + context 'when there is a cross modification' do + def do_queries + Project.transaction do + Project.where(id: -1).update_all(id: -1) + ::Ci::Pipeline.where(id: -1).update_all(id: -1) + end + end + + it 'detects cross modifications and tracks exception' do + expect(::Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + + subject + end + + context 'when the detect_cross_database_modification is disabled' do + before do + stub_feature_flags(detect_cross_database_modification: false) + end + + it 'does not detect cross modifications' do + expect(::Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception) + + subject + end + end + end + + context 'when there is no cross modification' do + def do_queries + Project.transaction do + Project.where(id: -1).update_all(id: -1) + Namespace.where(id: -1).update_all(id: -1) + end + end + + it 'does not log anything' do + expect(::Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception) + + subject + end + end + end + end +end diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb index 3a6fdd7642c..876069a1a92 100644 --- a/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb @@ -59,111 +59,6 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator, :aggregate_fai expect(validator.size_limit).to eq(2) end end - - context 'when the input mode is valid' do - it 'does not log a warning message' do - expect(::Sidekiq.logger).not_to receive(:warn) - - described_class.new(TestSizeLimiterWorker, job_payload, mode: 'track') - described_class.new(TestSizeLimiterWorker, job_payload, mode: 'compress') - end - end - - context 'when the input mode is invalid' do - it 'defaults to track mode and logs a warning message' do - expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter mode: invalid. Fallback to track mode.') - - validator = described_class.new(TestSizeLimiterWorker, job_payload, mode: 'invalid') - - expect(validator.mode).to eql('track') - end - end - - context 'when the input mode is empty' do - it 'defaults to track mode' do - expect(::Sidekiq.logger).not_to receive(:warn) - - validator = described_class.new(TestSizeLimiterWorker, job_payload, mode: nil) - - expect(validator.mode).to eql('track') - end - end - - context 'when the size input is valid' do - it 'does not log a warning message' do - expect(::Sidekiq.logger).not_to receive(:warn) - - described_class.new(TestSizeLimiterWorker, job_payload, size_limit: 300) - described_class.new(TestSizeLimiterWorker, job_payload, size_limit: 0) - end - end - - context 'when the size input is invalid' do - it 'logs a warning message' do - expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter limit: -1') - - validator = described_class.new(TestSizeLimiterWorker, job_payload, size_limit: -1) - - expect(validator.size_limit).to be(0) - end - end - - context 'when the size input is empty' do - it 'defaults to 0' do - expect(::Sidekiq.logger).not_to receive(:warn) - - validator = described_class.new(TestSizeLimiterWorker, job_payload, size_limit: nil) - - expect(validator.size_limit).to be(described_class::DEFAULT_SIZE_LIMIT) - end - end - - context 'when the compression threshold is valid' do - it 'does not log a warning message' do - expect(::Sidekiq.logger).not_to receive(:warn) - - described_class.new(TestSizeLimiterWorker, job_payload, compression_threshold: 300) - described_class.new(TestSizeLimiterWorker, job_payload, compression_threshold: 1) - end - end - - context 'when the compression threshold is negative' do - it 'logs a warning message' do - expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter compression threshold: -1') - - described_class.new(TestSizeLimiterWorker, job_payload, compression_threshold: -1) - end - - it 'falls back to the default' do - validator = described_class.new(TestSizeLimiterWorker, job_payload, compression_threshold: -1) - - expect(validator.compression_threshold).to be(100_000) - end - end - - context 'when the compression threshold is zero' do - it 'logs a warning message' do - expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter compression threshold: 0') - - described_class.new(TestSizeLimiterWorker, job_payload, compression_threshold: 0) - end - - it 'falls back to the default' do - validator = described_class.new(TestSizeLimiterWorker, job_payload, compression_threshold: 0) - - expect(validator.compression_threshold).to be(100_000) - end - end - - context 'when the compression threshold is empty' do - it 'defaults to 100_000' do - expect(::Sidekiq.logger).not_to receive(:warn) - - validator = described_class.new(TestSizeLimiterWorker, job_payload) - - expect(validator.compression_threshold).to be(100_000) - end - end end shared_examples 'validate limit job payload size' do @@ -171,20 +66,6 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator, :aggregate_fai let(:compression_threshold) { nil } let(:mode) { 'track' } - context 'when size limit negative' do - let(:size_limit) { -1 } - - it 'does not track jobs' do - expect(Gitlab::ErrorTracking).not_to receive(:track_exception) - - validate.call(TestSizeLimiterWorker, job_payload(a: 'a' * 300)) - end - - it 'does not raise exception' do - expect { validate.call(TestSizeLimiterWorker, job_payload(a: 'a' * 300)) }.not_to raise_error - end - end - context 'when size limit is 0' do let(:size_limit) { 0 } let(:job) { job_payload(a: 'a' * 300) } @@ -438,36 +319,20 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator, :aggregate_fai end describe '#validate!' do - context 'when creating an instance with the related configuration variables' do - let(:validate) do - ->(worker_clas, job) do - described_class.new(worker_class, job).validate! - end + let(:validate) do + ->(worker_class, job) do + described_class.new(worker_class, job).validate! end - - before do - stub_application_setting( - sidekiq_job_limiter_mode: mode, - sidekiq_job_limiter_compression_threshold_bytes: compression_threshold, - sidekiq_job_limiter_limit_bytes: size_limit - ) - end - - it_behaves_like 'validate limit job payload size' end - context 'when creating an instance with mode and size limit' do - let(:validate) do - ->(worker_clas, job) do - validator = described_class.new( - worker_class, job, - mode: mode, size_limit: size_limit, compression_threshold: compression_threshold - ) - validator.validate! - end - end - - it_behaves_like 'validate limit job payload size' + before do + stub_application_setting( + sidekiq_job_limiter_mode: mode, + sidekiq_job_limiter_compression_threshold_bytes: compression_threshold, + sidekiq_job_limiter_limit_bytes: size_limit + ) end + + it_behaves_like 'validate limit job payload size' end end 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 92a11c83a4a..b9a13fd697e 100644 --- a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do include ApplicationWorker - feature_category :issue_tracking + feature_category :team_planning def self.job_for_args(args) jobs.find { |job| job['args'] == args } @@ -78,8 +78,8 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do job1 = TestWithContextWorker.job_for_args(['job1', 1, 2, 3]) job2 = TestWithContextWorker.job_for_args(['job2', 1, 2, 3]) - expect(job1['meta.feature_category']).to eq('issue_tracking') - expect(job2['meta.feature_category']).to eq('issue_tracking') + expect(job1['meta.feature_category']).to eq('team_planning') + expect(job2['meta.feature_category']).to eq('team_planning') end it 'takes the feature category from the caller if the worker is not owned' do @@ -116,8 +116,8 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do job1 = TestWithContextWorker.job_for_args(['job1', 1, 2, 3]) job2 = TestWithContextWorker.job_for_args(['job2', 1, 2, 3]) - expect(job1['meta.feature_category']).to eq('issue_tracking') - expect(job2['meta.feature_category']).to eq('issue_tracking') + expect(job1['meta.feature_category']).to eq('team_planning') + expect(job2['meta.feature_category']).to eq('team_planning') end it 'takes the feature category from the caller if the worker is not owned' do diff --git a/spec/lib/gitlab/spamcheck/client_spec.rb b/spec/lib/gitlab/spamcheck/client_spec.rb index 15e963fe423..e542ce455bb 100644 --- a/spec/lib/gitlab/spamcheck/client_spec.rb +++ b/spec/lib/gitlab/spamcheck/client_spec.rb @@ -82,7 +82,7 @@ RSpec.describe Gitlab::Spamcheck::Client do end end - describe '#build_user_proto_buf', :aggregate_failures do + describe '#build_user_protobuf', :aggregate_failures do it 'builds the expected protobuf object' do user_pb = described_class.new.send(:build_user_protobuf, user) expect(user_pb.username).to eq user.username diff --git a/spec/lib/gitlab/subscription_portal_spec.rb b/spec/lib/gitlab/subscription_portal_spec.rb index a3808b0f0e2..4be1c85f7c8 100644 --- a/spec/lib/gitlab/subscription_portal_spec.rb +++ b/spec/lib/gitlab/subscription_portal_spec.rb @@ -9,14 +9,13 @@ RSpec.describe ::Gitlab::SubscriptionPortal do before do stub_env('CUSTOMER_PORTAL_URL', env_value) - stub_feature_flags(new_customersdot_staging_url: false) end describe '.default_subscriptions_url' do where(:test, :development, :result) do false | false | 'https://customers.gitlab.com' - false | true | 'https://customers.stg.gitlab.com' - true | false | 'https://customers.stg.gitlab.com' + false | true | 'https://customers.staging.gitlab.com' + true | false | 'https://customers.staging.gitlab.com' end before do @@ -35,7 +34,7 @@ RSpec.describe ::Gitlab::SubscriptionPortal do subject { described_class.subscriptions_url } context 'when CUSTOMER_PORTAL_URL ENV is unset' do - it { is_expected.to eq('https://customers.stg.gitlab.com') } + it { is_expected.to eq('https://customers.staging.gitlab.com') } end context 'when CUSTOMER_PORTAL_URL ENV is set' do @@ -55,15 +54,15 @@ RSpec.describe ::Gitlab::SubscriptionPortal do context 'url methods' do where(:method_name, :result) do - :default_subscriptions_url | 'https://customers.stg.gitlab.com' - :payment_form_url | 'https://customers.stg.gitlab.com/payment_forms/cc_validation' - :subscriptions_graphql_url | 'https://customers.stg.gitlab.com/graphql' - :subscriptions_more_minutes_url | 'https://customers.stg.gitlab.com/buy_pipeline_minutes' - :subscriptions_more_storage_url | 'https://customers.stg.gitlab.com/buy_storage' - :subscriptions_manage_url | 'https://customers.stg.gitlab.com/subscriptions' - :subscriptions_plans_url | 'https://customers.stg.gitlab.com/plans' - :subscriptions_instance_review_url | 'https://customers.stg.gitlab.com/instance_review' - :subscriptions_gitlab_plans_url | 'https://customers.stg.gitlab.com/gitlab_plans' + :default_subscriptions_url | 'https://customers.staging.gitlab.com' + :payment_form_url | 'https://customers.staging.gitlab.com/payment_forms/cc_validation' + :subscriptions_graphql_url | 'https://customers.staging.gitlab.com/graphql' + :subscriptions_more_minutes_url | 'https://customers.staging.gitlab.com/buy_pipeline_minutes' + :subscriptions_more_storage_url | 'https://customers.staging.gitlab.com/buy_storage' + :subscriptions_manage_url | 'https://customers.staging.gitlab.com/subscriptions' + :subscriptions_plans_url | 'https://about.gitlab.com/pricing/' + :subscriptions_instance_review_url | 'https://customers.staging.gitlab.com/instance_review' + :subscriptions_gitlab_plans_url | 'https://customers.staging.gitlab.com/gitlab_plans' end with_them do @@ -78,7 +77,7 @@ RSpec.describe ::Gitlab::SubscriptionPortal do let(:group_id) { 153 } - it { is_expected.to eq("https://customers.stg.gitlab.com/gitlab/namespaces/#{group_id}/extra_seats") } + it { is_expected.to eq("https://customers.staging.gitlab.com/gitlab/namespaces/#{group_id}/extra_seats") } end describe '.upgrade_subscription_url' do @@ -87,7 +86,7 @@ RSpec.describe ::Gitlab::SubscriptionPortal do let(:group_id) { 153 } let(:plan_id) { 5 } - it { is_expected.to eq("https://customers.stg.gitlab.com/gitlab/namespaces/#{group_id}/upgrade/#{plan_id}") } + it { is_expected.to eq("https://customers.staging.gitlab.com/gitlab/namespaces/#{group_id}/upgrade/#{plan_id}") } end describe '.renew_subscription_url' do @@ -95,6 +94,6 @@ RSpec.describe ::Gitlab::SubscriptionPortal do let(:group_id) { 153 } - it { is_expected.to eq("https://customers.stg.gitlab.com/gitlab/namespaces/#{group_id}/renew") } + it { is_expected.to eq("https://customers.staging.gitlab.com/gitlab/namespaces/#{group_id}/renew") } end end diff --git a/spec/lib/gitlab/tracking/destinations/product_analytics_spec.rb b/spec/lib/gitlab/tracking/destinations/product_analytics_spec.rb deleted file mode 100644 index 63e2e930acd..00000000000 --- a/spec/lib/gitlab/tracking/destinations/product_analytics_spec.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Tracking::Destinations::ProductAnalytics do - let(:emitter) { SnowplowTracker::Emitter.new('localhost', buffer_size: 1) } - let(:tracker) { SnowplowTracker::Tracker.new(emitter, SnowplowTracker::Subject.new, 'namespace', 'app_id') } - - describe '#event' do - shared_examples 'does not send an event' do - it 'does not send an event' do - expect_any_instance_of(SnowplowTracker::Tracker).not_to receive(:track_struct_event) - - subject.event(allowed_category, allowed_action) - end - end - - let(:allowed_category) { 'epics' } - let(:allowed_action) { 'promote' } - let(:self_monitoring_project) { create(:project) } - - before do - stub_feature_flags(product_analytics_tracking: true) - stub_application_setting(self_monitoring_project_id: self_monitoring_project.id) - stub_application_setting(usage_ping_enabled: true) - end - - context 'with allowed event' do - it 'sends an event to Product Analytics snowplow collector' do - expect(SnowplowTracker::AsyncEmitter) - .to receive(:new) - .with(ProductAnalytics::Tracker::COLLECTOR_URL, protocol: Gitlab.config.gitlab.protocol) - .and_return(emitter) - - expect(SnowplowTracker::Tracker) - .to receive(:new) - .with(emitter, an_instance_of(SnowplowTracker::Subject), Gitlab::Tracking::SNOWPLOW_NAMESPACE, self_monitoring_project.id.to_s) - .and_return(tracker) - - freeze_time do - expect(tracker) - .to receive(:track_struct_event) - .with(allowed_category, allowed_action, 'label', 'property', 1.5, nil, (Time.now.to_f * 1000).to_i) - - subject.event(allowed_category, allowed_action, label: 'label', property: 'property', value: 1.5) - end - end - end - - context 'with non-allowed event' do - it 'does not send an event' do - expect_any_instance_of(SnowplowTracker::Tracker).not_to receive(:track_struct_event) - - subject.event('category', 'action') - subject.event(allowed_category, 'action') - subject.event('category', allowed_action) - end - end - - context 'when self-monitoring project does not exist' do - before do - stub_application_setting(self_monitoring_project_id: nil) - end - - include_examples 'does not send an event' - end - - context 'when product_analytics_tracking FF is disabled' do - before do - stub_feature_flags(product_analytics_tracking: false) - end - - include_examples 'does not send an event' - end - - context 'when usage ping is disabled' do - before do - stub_application_setting(usage_ping_enabled: false) - end - - include_examples 'does not send an event' - end - end -end diff --git a/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb b/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb new file mode 100644 index 00000000000..6004698d092 --- /dev/null +++ b/spec/lib/gitlab/tracking/destinations/snowplow_micro_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Tracking::Destinations::SnowplowMicro do + include StubENV + + before do + stub_application_setting(snowplow_enabled: true) + stub_env('SNOWPLOW_MICRO_ENABLE', '1') + allow(Rails.env).to receive(:development?).and_return(true) + end + + describe '#hostname' do + context 'when SNOWPLOW_MICRO_URI is set' do + before do + stub_env('SNOWPLOW_MICRO_URI', 'http://gdk.test:9091') + end + + it 'returns hostname URI part' do + expect(subject.hostname).to eq('gdk.test:9091') + end + end + + context 'when SNOWPLOW_MICRO_URI is without protocol' do + before do + stub_env('SNOWPLOW_MICRO_URI', 'gdk.test:9091') + end + + it 'returns hostname URI part' do + expect(subject.hostname).to eq('gdk.test:9091') + end + end + + context 'when SNOWPLOW_MICRO_URI is hostname only' do + before do + stub_env('SNOWPLOW_MICRO_URI', 'uriwithoutport') + end + + it 'returns hostname URI with default HTTP port' do + expect(subject.hostname).to eq('uriwithoutport:80') + end + end + + context 'when SNOWPLOW_MICRO_URI is not set' do + it 'returns localhost hostname' do + expect(subject.hostname).to eq('localhost:9090') + end + end + end +end diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb index 8ded80dd191..7d678db5ec8 100644 --- a/spec/lib/gitlab/tracking/standard_context_spec.rb +++ b/spec/lib/gitlab/tracking/standard_context_spec.rb @@ -99,25 +99,5 @@ RSpec.describe Gitlab::Tracking::StandardContext do it 'accepts just project id as integer' do expect { described_class.new(project: 1).to_context }.not_to raise_error end - - context 'without add_namespace_and_project_to_snowplow_tracking feature' do - before do - stub_feature_flags(add_namespace_and_project_to_snowplow_tracking: false) - end - - it 'does not contain project or namespace ids' do - expect(snowplow_context.to_json[:data].keys).not_to include(:project_id, :namespace_id) - end - end - - context 'without add_actor_based_user_to_snowplow_tracking feature' do - before do - stub_feature_flags(add_actor_based_user_to_snowplow_tracking: false) - end - - it 'does not contain user_id' do - expect(snowplow_context.to_json[:data].keys).not_to include(:user_id) - end - end end end diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb index dacaae55676..61b2c89ffa1 100644 --- a/spec/lib/gitlab/tracking_spec.rb +++ b/spec/lib/gitlab/tracking_spec.rb @@ -2,6 +2,8 @@ require 'spec_helper' RSpec.describe Gitlab::Tracking do + include StubENV + before do stub_application_setting(snowplow_enabled: true) stub_application_setting(snowplow_collector_hostname: 'gitfoo.com') @@ -12,17 +14,62 @@ RSpec.describe Gitlab::Tracking do end describe '.options' do - it 'returns useful client options' do - expected_fields = { - namespace: 'gl', - hostname: 'gitfoo.com', - cookieDomain: '.gitfoo.com', - appId: '_abc123_', - formTracking: true, - linkClickTracking: true - } - - expect(subject.options(nil)).to match(expected_fields) + shared_examples 'delegates to destination' do |klass| + before do + allow_next_instance_of(klass) do |instance| + allow(instance).to receive(:options).and_call_original + end + end + + it "delegates to #{klass} destination" do + expect_next_instance_of(klass) do |instance| + expect(instance).to receive(:options) + end + + subject.options(nil) + end + end + + context 'when destination is Snowplow' do + it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::Snowplow + + it 'returns useful client options' do + expected_fields = { + namespace: 'gl', + hostname: 'gitfoo.com', + cookieDomain: '.gitfoo.com', + appId: '_abc123_', + formTracking: true, + linkClickTracking: true + } + + expect(subject.options(nil)).to match(expected_fields) + end + end + + context 'when destination is SnowplowMicro' do + before do + stub_env('SNOWPLOW_MICRO_ENABLE', '1') + allow(Rails.env).to receive(:development?).and_return(true) + end + + it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::SnowplowMicro + + it 'returns useful client options' do + expected_fields = { + namespace: 'gl', + hostname: 'localhost:9090', + cookieDomain: '.gitlab.com', + appId: '_abc123_', + protocol: 'http', + port: 9090, + force_secure_tracker: false, + formTracking: true, + linkClickTracking: true + } + + expect(subject.options(nil)).to match(expected_fields) + end end it 'when feature flag is disabled' do @@ -41,7 +88,6 @@ RSpec.describe Gitlab::Tracking do shared_examples 'delegates to destination' do |klass| before do allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow).to receive(:event) - allow_any_instance_of(Gitlab::Tracking::Destinations::ProductAnalytics).to receive(:event) end it "delegates to #{klass} destination" do @@ -72,8 +118,23 @@ RSpec.describe Gitlab::Tracking do end end - it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::Snowplow - it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::ProductAnalytics + context 'when destination is Snowplow' do + before do + stub_env('SNOWPLOW_MICRO_ENABLE', '0') + allow(Rails.env).to receive(:development?).and_return(true) + end + + it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::Snowplow + end + + context 'when destination is SnowplowMicro' do + before do + stub_env('SNOWPLOW_MICRO_ENABLE', '1') + allow(Rails.env).to receive(:development?).and_return(true) + end + + it_behaves_like 'delegates to destination', Gitlab::Tracking::Destinations::SnowplowMicro + end it 'tracks errors' do expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with( diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb index 522f69062fb..a22b3a733bd 100644 --- a/spec/lib/gitlab/usage/metric_definition_spec.rb +++ b/spec/lib/gitlab/usage/metric_definition_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Gitlab::Usage::MetricDefinition do value_type: 'string', product_category: 'collection', product_stage: 'growth', + product_section: 'devops', status: 'active', milestone: '14.1', default_generation: 'generation_1', @@ -222,6 +223,7 @@ RSpec.describe Gitlab::Usage::MetricDefinition do value_type: 'string', product_category: 'collection', product_stage: 'growth', + product_section: 'devops', status: 'active', milestone: '14.1', default_generation: 'generation_1', diff --git a/spec/lib/gitlab/usage/metric_spec.rb b/spec/lib/gitlab/usage/metric_spec.rb index ea8d1a135a6..19d2d3048eb 100644 --- a/spec/lib/gitlab/usage/metric_spec.rb +++ b/spec/lib/gitlab/usage/metric_spec.rb @@ -45,4 +45,10 @@ RSpec.describe Gitlab::Usage::Metric do expect(described_class.new(issue_count_metric_definiton).with_instrumentation).to eq({ counts: { issues: "SELECT COUNT(\"issues\".\"id\") FROM \"issues\"" } }) end end + + describe '#with_suggested_name' do + it 'returns key_path metric with the corresponding generated query' do + expect(described_class.new(issue_count_metric_definiton).with_suggested_name).to eq({ counts: { issues: 'count_issues' } }) + end + end end diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/generic_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/generic_metric_spec.rb index 158be34d39c..c8cb1bb4373 100644 --- a/spec/lib/gitlab/usage/metrics/instrumentations/generic_metric_spec.rb +++ b/spec/lib/gitlab/usage/metrics/instrumentations/generic_metric_spec.rb @@ -7,18 +7,18 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::GenericMetric do subject do Class.new(described_class) do fallback(custom_fallback) - value { Gitlab::Database.main.version } + value { ApplicationRecord.database.version } end.new(time_frame: 'none') end describe '#value' do it 'gives the correct value' do - expect(subject.value).to eq(Gitlab::Database.main.version) + expect(subject.value).to eq(ApplicationRecord.database.version) end context 'when raising an exception' do it 'return the custom fallback' do - expect(Gitlab::Database.main).to receive(:version).and_raise('Error') + expect(ApplicationRecord.database).to receive(:version).and_raise('Error') expect(subject.value).to eq(custom_fallback) end end @@ -28,18 +28,18 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::GenericMetric do context 'with default fallback' do subject do Class.new(described_class) do - value { Gitlab::Database.main.version } + value { ApplicationRecord.database.version } end.new(time_frame: 'none') end describe '#value' do it 'gives the correct value' do - expect(subject.value).to eq(Gitlab::Database.main.version ) + expect(subject.value).to eq(ApplicationRecord.database.version ) end context 'when raising an exception' do it 'return the default fallback' do - expect(Gitlab::Database.main).to receive(:version).and_raise('Error') + expect(ApplicationRecord.database).to receive(:version).and_raise('Error') expect(subject.value).to eq(described_class::FALLBACK) end end diff --git a/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb b/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb index 0f95da74ff9..dbbc718e147 100644 --- a/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb +++ b/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb @@ -25,10 +25,30 @@ RSpec.describe Gitlab::Usage::Metrics::NamesSuggestions::Generator do end context 'for count with default column metrics' do - it_behaves_like 'name suggestion' do - # corresponding metric is collected with count(Board) - let(:key_path) { 'counts.boards' } - let(:name_suggestion) { /count_boards/ } + context 'with usage_data_instrumentation feature flag' do + context 'when enabled' do + before do + stub_feature_flags(usage_data_instrumentation: true) + end + + it_behaves_like 'name suggestion' do + # corresponding metric is collected with ::Gitlab::UsageDataMetrics.suggested_names + let(:key_path) { 'counts.boards' } + let(:name_suggestion) { /count_boards/ } + end + end + + context 'when disabled' do + before do + stub_feature_flags(usage_data_instrumentation: false) + end + + it_behaves_like 'name suggestion' do + # corresponding metric is collected with count(Board) + let(:key_path) { 'counts.boards' } + let(:name_suggestion) { /count_boards/ } + end + end end end diff --git a/spec/lib/gitlab/usage_data_counters/vs_code_extenion_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/vs_code_extenion_activity_unique_counter_spec.rb deleted file mode 100644 index 7593d51fe76..00000000000 --- a/spec/lib/gitlab/usage_data_counters/vs_code_extenion_activity_unique_counter_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.shared_examples 'a tracked vs code unique action' do |event| - before do - stub_application_setting(usage_ping_enabled: true) - end - - def count_unique(date_from:, date_to:) - Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: action, start_date: date_from, end_date: date_to) - end - - it 'tracks when the user agent is from vs code' do - aggregate_failures do - user_agent = { user_agent: 'vs-code-gitlab-workflow/3.11.1 VSCode/1.52.1 Node.js/12.14.1 (darwin; x64)' } - - expect(track_action(user: user1, **user_agent)).to be_truthy - expect(track_action(user: user1, **user_agent)).to be_truthy - expect(track_action(user: user2, **user_agent)).to be_truthy - - expect(count_unique(date_from: time - 1.week, date_to: time + 1.week)).to eq(2) - end - end - - it 'does not track when the user agent is not from vs code' do - aggregate_failures do - user_agent = { user_agent: 'normal_user_agent' } - - expect(track_action(user: user1, **user_agent)).to be_falsey - expect(track_action(user: user1, **user_agent)).to be_falsey - expect(track_action(user: user2, **user_agent)).to be_falsey - - expect(count_unique(date_from: time - 1.week, date_to: time + 1.week)).to eq(0) - end - end - - it 'does not track if user agent is not present' do - expect(track_action(user: nil, user_agent: nil)).to be_nil - end - - it 'does not track if user is not present' do - user_agent = { user_agent: 'vs-code-gitlab-workflow/3.11.1 VSCode/1.52.1 Node.js/12.14.1 (darwin; x64)' } - - expect(track_action(user: nil, **user_agent)).to be_nil - end -end - -RSpec.describe Gitlab::UsageDataCounters::VSCodeExtensionActivityUniqueCounter, :clean_gitlab_redis_shared_state do - let(:user1) { build(:user, id: 1) } - let(:user2) { build(:user, id: 2) } - let(:time) { Time.current } - - context 'when tracking a vs code api request' do - it_behaves_like 'a tracked vs code unique action' do - let(:action) { described_class::VS_CODE_API_REQUEST_ACTION } - - def track_action(params) - described_class.track_api_request_when_trackable(**params) - end - end - end -end diff --git a/spec/lib/gitlab/usage_data_counters/vscode_extenion_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/vscode_extenion_activity_unique_counter_spec.rb new file mode 100644 index 00000000000..7593d51fe76 --- /dev/null +++ b/spec/lib/gitlab/usage_data_counters/vscode_extenion_activity_unique_counter_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'a tracked vs code unique action' do |event| + before do + stub_application_setting(usage_ping_enabled: true) + end + + def count_unique(date_from:, date_to:) + Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: action, start_date: date_from, end_date: date_to) + end + + it 'tracks when the user agent is from vs code' do + aggregate_failures do + user_agent = { user_agent: 'vs-code-gitlab-workflow/3.11.1 VSCode/1.52.1 Node.js/12.14.1 (darwin; x64)' } + + expect(track_action(user: user1, **user_agent)).to be_truthy + expect(track_action(user: user1, **user_agent)).to be_truthy + expect(track_action(user: user2, **user_agent)).to be_truthy + + expect(count_unique(date_from: time - 1.week, date_to: time + 1.week)).to eq(2) + end + end + + it 'does not track when the user agent is not from vs code' do + aggregate_failures do + user_agent = { user_agent: 'normal_user_agent' } + + expect(track_action(user: user1, **user_agent)).to be_falsey + expect(track_action(user: user1, **user_agent)).to be_falsey + expect(track_action(user: user2, **user_agent)).to be_falsey + + expect(count_unique(date_from: time - 1.week, date_to: time + 1.week)).to eq(0) + end + end + + it 'does not track if user agent is not present' do + expect(track_action(user: nil, user_agent: nil)).to be_nil + end + + it 'does not track if user is not present' do + user_agent = { user_agent: 'vs-code-gitlab-workflow/3.11.1 VSCode/1.52.1 Node.js/12.14.1 (darwin; x64)' } + + expect(track_action(user: nil, **user_agent)).to be_nil + end +end + +RSpec.describe Gitlab::UsageDataCounters::VSCodeExtensionActivityUniqueCounter, :clean_gitlab_redis_shared_state do + let(:user1) { build(:user, id: 1) } + let(:user2) { build(:user, id: 2) } + let(:time) { Time.current } + + context 'when tracking a vs code api request' do + it_behaves_like 'a tracked vs code unique action' do + let(:action) { described_class::VS_CODE_API_REQUEST_ACTION } + + def track_action(params) + described_class.track_api_request_when_trackable(**params) + end + end + end +end diff --git a/spec/lib/gitlab/usage_data_metrics_spec.rb b/spec/lib/gitlab/usage_data_metrics_spec.rb index ee0cfb1407e..563eed75c38 100644 --- a/spec/lib/gitlab/usage_data_metrics_spec.rb +++ b/spec/lib/gitlab/usage_data_metrics_spec.rb @@ -13,7 +13,9 @@ RSpec.describe Gitlab::UsageDataMetrics do end before do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + allow_next_instance_of(Gitlab::Database::BatchCounter) do |batch_counter| + allow(batch_counter).to receive(:transaction_open?).and_return(false) + end end context 'with instrumentation_class' do @@ -76,4 +78,16 @@ RSpec.describe Gitlab::UsageDataMetrics do end end end + + describe '.suggested_names' do + subject { described_class.suggested_names } + + let(:suggested_names) do + ::Gitlab::Usage::Metric.all.map(&:with_suggested_name).reduce({}, :deep_merge) + end + + it 'includes Service Ping suggested names' do + expect(subject).to match_array(suggested_names) + end + end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 833bf260019..cf544c07195 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -80,6 +80,12 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end end end + + it 'allows indifferent access' do + allow(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:unique_events).and_return(1) + expect(subject[:search_unique_visits][:search_unique_visits_for_any_target_monthly]).to eq(1) + expect(subject[:search_unique_visits]['search_unique_visits_for_any_target_monthly']).to eq(1) + end end describe 'usage_activity_by_stage_package' do @@ -187,6 +193,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end describe 'usage_activity_by_stage_manage' do + let_it_be(:error_rate) { Gitlab::Database::PostgresHll::BatchDistinctCounter::ERROR_RATE } + it 'includes accurate usage_activity_by_stage data' do stub_config( omniauth: @@ -207,14 +215,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end expect(described_class.usage_activity_by_stage_manage({})).to include( - events: 2, + events: -1, groups: 2, users_created: 6, omniauth_providers: ['google_oauth2'], user_auth_by_provider: { 'group_saml' => 2, 'ldap' => 4, 'standard' => 0, 'two-factor' => 0, 'two-factor-via-u2f-device' => 0, "two-factor-via-webauthn-device" => 0 } ) expect(described_class.usage_activity_by_stage_manage(described_class.monthly_time_range_db_params)).to include( - events: 1, + events: be_within(error_rate).percent_of(1), groups: 1, users_created: 3, omniauth_providers: ['google_oauth2'], @@ -367,9 +375,9 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do def omniauth_providers [ - OpenStruct.new(name: 'google_oauth2'), - OpenStruct.new(name: 'ldapmain'), - OpenStruct.new(name: 'group_saml') + double('provider', name: 'google_oauth2'), + double('provider', name: 'ldapmain'), + double('provider', name: 'group_saml') ] end end @@ -428,7 +436,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end expect(described_class.usage_activity_by_stage_plan({})).to include( - issues: 3, notes: 2, projects: 2, todos: 2, @@ -439,7 +446,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do projects_jira_dvcs_server_active: 2 ) expect(described_class.usage_activity_by_stage_plan(described_class.monthly_time_range_db_params)).to include( - issues: 2, notes: 1, projects: 1, todos: 1, @@ -450,6 +456,44 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do projects_jira_dvcs_server_active: 1 ) end + + context 'with usage_data_instrumentation feature flag' do + context 'when enabled' do + it 'merges the data from instrumentation classes' do + stub_feature_flags(usage_data_instrumentation: true) + + for_defined_days_back do + user = create(:user) + project = create(:project, creator: user) + create(:issue, project: project, author: user) + create(:issue, project: project, author: User.support_bot) + end + + expect(described_class.usage_activity_by_stage_plan({})).to include(issues: Gitlab::Utils::UsageData::INSTRUMENTATION_CLASS_FALLBACK) + expect(described_class.usage_activity_by_stage_plan(described_class.monthly_time_range_db_params)).to include(issues: Gitlab::Utils::UsageData::INSTRUMENTATION_CLASS_FALLBACK) + + uncached_data = described_class.uncached_data + expect(uncached_data[:usage_activity_by_stage][:plan]).to include(issues: 3) + expect(uncached_data[:usage_activity_by_stage_monthly][:plan]).to include(issues: 2) + end + end + + context 'when disabled' do + it 'does not merge the data from instrumentation classes' do + stub_feature_flags(usage_data_instrumentation: false) + + for_defined_days_back do + user = create(:user) + project = create(:project, creator: user) + create(:issue, project: project, author: user) + create(:issue, project: project, author: User.support_bot) + end + + expect(described_class.usage_activity_by_stage_plan({})).to include(issues: 3) + expect(described_class.usage_activity_by_stage_plan(described_class.monthly_time_range_db_params)).to include(issues: 2) + end + end + end end describe 'usage_activity_by_stage_release' do @@ -466,17 +510,53 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do deployments: 2, failed_deployments: 2, releases: 2, - successful_deployments: 2, - releases_with_milestones: 2 + successful_deployments: 2 ) expect(described_class.usage_activity_by_stage_release(described_class.monthly_time_range_db_params)).to include( deployments: 1, failed_deployments: 1, releases: 1, - successful_deployments: 1, - releases_with_milestones: 1 + successful_deployments: 1 ) end + + context 'with usage_data_instrumentation feature flag' do + before do + for_defined_days_back do + user = create(:user) + create(:deployment, :failed, user: user) + release = create(:release, author: user) + create(:milestone, project: release.project, releases: [release]) + create(:deployment, :success, user: user) + end + end + + context 'when enabled' do + before do + stub_feature_flags(usage_data_instrumentation: true) + end + + it 'merges data from instrumentation classes' do + expect(described_class.usage_activity_by_stage_release({})).to include(releases_with_milestones: Gitlab::Utils::UsageData::INSTRUMENTATION_CLASS_FALLBACK) + expect(described_class.usage_activity_by_stage_release(described_class.monthly_time_range_db_params)).to include(releases_with_milestones: Gitlab::Utils::UsageData::INSTRUMENTATION_CLASS_FALLBACK) + + uncached_data = described_class.uncached_data + expect(uncached_data[:usage_activity_by_stage][:release]).to include(releases_with_milestones: 2) + expect(uncached_data[:usage_activity_by_stage_monthly][:release]).to include(releases_with_milestones: 1) + end + end + + context 'when disabled' do + before do + stub_feature_flags(usage_data_instrumentation: false) + end + + it 'does not merge data from instrumentation classes' do + expect(described_class.usage_activity_by_stage_release({})).to include(releases_with_milestones: 2) + expect(described_class.usage_activity_by_stage_release(described_class.monthly_time_range_db_params)).to include(releases_with_milestones: 1) + end + end + end end describe 'usage_activity_by_stage_verify' do @@ -525,16 +605,16 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do subject { described_class.data } it 'gathers usage data' do - expect(subject.keys).to include(*UsageDataHelpers::USAGE_DATA_KEYS) + expect(subject.keys).to include(*UsageDataHelpers::USAGE_DATA_KEYS.map(&:to_s)) end it 'gathers usage counts', :aggregate_failures do count_data = subject[:counts] - expect(count_data[:boards]).to eq(1) expect(count_data[:projects]).to eq(4) - expect(count_data.keys).to include(*UsageDataHelpers::COUNTS_KEYS) - expect(UsageDataHelpers::COUNTS_KEYS - count_data.keys).to be_empty + count_keys = UsageDataHelpers::COUNTS_KEYS.map(&:to_s) + expect(count_data.keys).to include(*count_keys) + expect(count_keys - count_data.keys).to be_empty expect(count_data.values).to all(be_a_kind_of(Integer)) end @@ -619,7 +699,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do external_diffs: { enabled: false }, lfs: { enabled: true, object_store: { enabled: false, direct_upload: true, background_upload: false, provider: "AWS" } }, uploads: { enabled: nil, object_store: { enabled: false, direct_upload: true, background_upload: false, provider: "AWS" } }, - packages: { enabled: true, object_store: { enabled: false, direct_upload: false, background_upload: true, provider: "AWS" } } } + packages: { enabled: true, object_store: { enabled: false, direct_upload: false, background_upload: true, provider: "AWS" } } }.with_indifferent_access ) end @@ -793,12 +873,37 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do subject { described_class.license_usage_data } it 'gathers license data' do - expect(subject[:uuid]).to eq(Gitlab::CurrentSettings.uuid) expect(subject[:version]).to eq(Gitlab::VERSION) expect(subject[:installation_type]).to eq('gitlab-development-kit') - expect(subject[:active_user_count]).to eq(User.active.size) expect(subject[:recorded_at]).to be_a(Time) end + + context 'with usage_data_instrumentation feature flag' do + context 'when enabled' do + it 'merges uuid and hostname data from instrumentation classes' do + stub_feature_flags(usage_data_instrumentation: true) + + expect(subject[:uuid]).to eq(Gitlab::Utils::UsageData::INSTRUMENTATION_CLASS_FALLBACK) + expect(subject[:hostname]).to eq(Gitlab::Utils::UsageData::INSTRUMENTATION_CLASS_FALLBACK) + expect(subject[:active_user_count]).to eq(Gitlab::Utils::UsageData::INSTRUMENTATION_CLASS_FALLBACK) + + uncached_data = described_class.data + expect(uncached_data[:uuid]).to eq(Gitlab::CurrentSettings.uuid) + expect(uncached_data[:hostname]).to eq(Gitlab.config.gitlab.host) + expect(uncached_data[:active_user_count]).to eq(User.active.size) + end + end + + context 'when disabled' do + it 'does not merge uuid and hostname data from instrumentation classes' do + stub_feature_flags(usage_data_instrumentation: false) + + expect(subject[:uuid]).to eq(Gitlab::CurrentSettings.uuid) + expect(subject[:hostname]).to eq(Gitlab.config.gitlab.host) + expect(subject[:active_user_count]).to eq(User.active.size) + end + end + end end context 'when not relying on database records' do @@ -873,9 +978,9 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(subject[:gitlab_pages][:enabled]).to eq(Gitlab.config.pages.enabled) expect(subject[:gitlab_pages][:version]).to eq(Gitlab::Pages::VERSION) expect(subject[:git][:version]).to eq(Gitlab::Git.version) - expect(subject[:database][:adapter]).to eq(Gitlab::Database.main.adapter_name) - expect(subject[:database][:version]).to eq(Gitlab::Database.main.version) - expect(subject[:database][:pg_system_id]).to eq(Gitlab::Database.main.system_id) + expect(subject[:database][:adapter]).to eq(ApplicationRecord.database.adapter_name) + expect(subject[:database][:version]).to eq(ApplicationRecord.database.version) + expect(subject[:database][:pg_system_id]).to eq(ApplicationRecord.database.system_id) expect(subject[:mail][:smtp_server]).to eq(ActionMailer::Base.smtp_settings[:address]) expect(subject[:gitaly][:version]).to be_present expect(subject[:gitaly][:servers]).to be >= 1 @@ -1061,18 +1166,46 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(subject[:settings][:gitaly_apdex]).to be_within(0.001).of(0.95) end - it 'reports collected data categories' do - expected_value = %w[standard subscription operational optional] + context 'with usage_data_instrumentation feature flag' do + context 'when enabled' do + before do + stub_feature_flags(usage_data_instrumentation: true) + end + + it 'reports collected data categories' do + expected_value = %w[standard subscription operational optional] + + allow_next_instance_of(ServicePing::PermitDataCategoriesService) do |instance| + expect(instance).to receive(:execute).and_return(expected_value) + end + + expect(described_class.data[:settings][:collected_data_categories]).to eq(expected_value) + end - allow_next_instance_of(ServicePing::PermitDataCategoriesService) do |instance| - expect(instance).to receive(:execute).and_return(expected_value) + it 'gathers service_ping_features_enabled' do + expect(described_class.data[:settings][:service_ping_features_enabled]).to eq(Gitlab::CurrentSettings.usage_ping_features_enabled) + end end - expect(subject[:settings][:collected_data_categories]).to eq(expected_value) - end + context 'when disabled' do + before do + stub_feature_flags(usage_data_instrumentation: false) + end + + it 'reports collected data categories' do + expected_value = %w[standard subscription operational optional] - it 'gathers service_ping_features_enabled' do - expect(subject[:settings][:service_ping_features_enabled]).to eq(Gitlab::CurrentSettings.usage_ping_features_enabled) + allow_next_instance_of(ServicePing::PermitDataCategoriesService) do |instance| + expect(instance).to receive(:execute).and_return(expected_value) + end + + expect(subject[:settings][:collected_data_categories]).to eq(expected_value) + end + + it 'gathers service_ping_features_enabled' do + expect(subject[:settings][:service_ping_features_enabled]).to eq(Gitlab::CurrentSettings.usage_ping_features_enabled) + end + end end it 'gathers user_cap_feature_enabled' do diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb index 1d01d5c7e6a..e721b28ac29 100644 --- a/spec/lib/gitlab/utils/usage_data_spec.rb +++ b/spec/lib/gitlab/utils/usage_data_spec.rb @@ -8,8 +8,26 @@ RSpec.describe Gitlab::Utils::UsageData do describe '#add_metric' do let(:metric) { 'UuidMetric'} - it 'computes the metric value for given metric' do - expect(described_class.add_metric(metric)).to eq(Gitlab::CurrentSettings.uuid) + context 'with usage_data_instrumentation feature flag' do + context 'when enabled' do + before do + stub_feature_flags(usage_data_instrumentation: true) + end + + it 'returns -100 value to be overriden' do + expect(described_class.add_metric(metric)).to eq(-100) + end + end + + context 'when disabled' do + before do + stub_feature_flags(usage_data_instrumentation: false) + end + + it 'computes the metric value for given metric' do + expect(described_class.add_metric(metric)).to eq(Gitlab::CurrentSettings.uuid) + end + end end end @@ -52,7 +70,7 @@ RSpec.describe Gitlab::Utils::UsageData do let(:relation) { double(:relation, connection: double(:connection)) } before do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) # rubocop: disable Database/MultipleDatabases + allow(relation.connection).to receive(:transaction_open?).and_return(false) end it 'delegates counting to counter class instance' do @@ -104,7 +122,7 @@ RSpec.describe Gitlab::Utils::UsageData do let(:ci_builds_estimated_cardinality) { 2.0809220082170614 } before do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) # rubocop: disable Database/MultipleDatabases + allow(model.connection).to receive(:transaction_open?).and_return(false) end context 'different counting parameters' do diff --git a/spec/lib/gitlab/webpack/file_loader_spec.rb b/spec/lib/gitlab/webpack/file_loader_spec.rb new file mode 100644 index 00000000000..34d00b9f106 --- /dev/null +++ b/spec/lib/gitlab/webpack/file_loader_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'support/helpers/file_read_helpers' +require 'support/webmock' + +RSpec.describe Gitlab::Webpack::FileLoader do + include FileReadHelpers + include WebMock::API + + let(:error_file_path) { "error.yml" } + let(:file_path) { "my_test_file.yml" } + let(:file_contents) do + <<-EOF + - hello + - world + - test + EOF + end + + before do + allow(Gitlab.config.webpack.dev_server).to receive_messages(host: 'hostname', port: 2000, https: false) + allow(Gitlab.config.webpack).to receive(:public_path).and_return('public_path') + allow(Gitlab.config.webpack).to receive(:output_dir).and_return('webpack_output') + end + + context "with dev server enabled" do + before do + allow(Gitlab.config.webpack.dev_server).to receive(:enabled).and_return(true) + + stub_request(:get, "http://hostname:2000/public_path/not_found").to_return(status: 404) + stub_request(:get, "http://hostname:2000/public_path/#{file_path}").to_return(body: file_contents, status: 200) + stub_request(:get, "http://hostname:2000/public_path/#{error_file_path}").to_raise(StandardError) + end + + it "returns content when respondes succesfully" do + expect(Gitlab::Webpack::FileLoader.load(file_path)).to be(file_contents) + end + + it "raises error when 404" do + expect { Gitlab::Webpack::FileLoader.load("not_found") }.to raise_error("HTTP error 404") + end + + it "raises error when errors out" do + expect { Gitlab::Webpack::FileLoader.load(error_file_path) }.to raise_error(Gitlab::Webpack::FileLoader::DevServerLoadError) + end + end + + context "with dev server enabled and https" do + before do + allow(Gitlab.config.webpack.dev_server).to receive(:enabled).and_return(true) + allow(Gitlab.config.webpack.dev_server).to receive(:https).and_return(true) + + stub_request(:get, "https://hostname:2000/public_path/#{error_file_path}").to_raise(EOFError) + end + + it "raises error if catches SSLError" do + expect { Gitlab::Webpack::FileLoader.load(error_file_path) }.to raise_error(Gitlab::Webpack::FileLoader::DevServerSSLError) + end + end + + context "with dev server disabled" do + before do + allow(Gitlab.config.webpack.dev_server).to receive(:enabled).and_return(false) + stub_file_read(::Rails.root.join("webpack_output/#{file_path}"), content: file_contents) + stub_file_read(::Rails.root.join("webpack_output/#{error_file_path}"), error: Errno::ENOENT) + end + + describe ".load" do + it "returns file content from file path" do + expect(Gitlab::Webpack::FileLoader.load(file_path)).to be(file_contents) + end + + it "throws error if file cannot be read" do + expect { Gitlab::Webpack::FileLoader.load(error_file_path) }.to raise_error(Gitlab::Webpack::FileLoader::StaticLoadError) + end + end + end +end diff --git a/spec/lib/gitlab/webpack/graphql_known_operations_spec.rb b/spec/lib/gitlab/webpack/graphql_known_operations_spec.rb new file mode 100644 index 00000000000..89cade82fe6 --- /dev/null +++ b/spec/lib/gitlab/webpack/graphql_known_operations_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Webpack::GraphqlKnownOperations do + let(:content) do + <<-EOF + - hello + - world + - test + EOF + end + + around do |example| + described_class.clear_memoization! + + example.run + + described_class.clear_memoization! + end + + describe ".load" do + context "when file loader returns" do + before do + allow(::Gitlab::Webpack::FileLoader).to receive(:load).with("graphql_known_operations.yml").and_return(content) + end + + it "returns memoized value" do + expect(::Gitlab::Webpack::FileLoader).to receive(:load).once + + 2.times { ::Gitlab::Webpack::GraphqlKnownOperations.load } + + expect(::Gitlab::Webpack::GraphqlKnownOperations.load).to eq(%w(hello world test)) + end + end + + context "when file loader errors" do + before do + allow(::Gitlab::Webpack::FileLoader).to receive(:load).and_raise(StandardError.new("test")) + end + + it "returns empty array" do + expect(::Gitlab::Webpack::GraphqlKnownOperations.load).to eq([]) + end + end + end +end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index 8ba56af561d..3bab9aec454 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -512,6 +512,24 @@ RSpec.describe Gitlab::Workhorse do end end + describe '.send_dependency' do + let(:headers) { { Accept: 'foo', Authorization: 'Bearer asdf1234' } } + let(:url) { 'https://foo.bar.com/baz' } + + subject { described_class.send_dependency(headers, url) } + + it 'sets the header correctly', :aggregate_failures do + key, command, params = decode_workhorse_header(subject) + + expect(key).to eq("Gitlab-Workhorse-Send-Data") + expect(command).to eq("send-dependency") + expect(params).to eq({ + 'Header' => headers, + 'Url' => url + }.deep_stringify_keys) + end + end + describe '.send_git_snapshot' do let(:url) { 'http://example.com' } diff --git a/spec/lib/gitlab/x509/certificate_spec.rb b/spec/lib/gitlab/x509/certificate_spec.rb index a5b192dd051..2dc30cc871d 100644 --- a/spec/lib/gitlab/x509/certificate_spec.rb +++ b/spec/lib/gitlab/x509/certificate_spec.rb @@ -5,6 +5,9 @@ require 'spec_helper' RSpec.describe Gitlab::X509::Certificate do include SmimeHelper + let(:sample_ca_certs_path) { Rails.root.join('spec/fixtures/clusters').to_s } + let(:sample_cert) { Rails.root.join('spec/fixtures/x509_certificate.crt').to_s } + # cert generation is an expensive operation and they are used read-only, # so we share them as instance variables in all tests before :context do @@ -13,6 +16,16 @@ RSpec.describe Gitlab::X509::Certificate do @cert = generate_cert(signer_ca: @intermediate_ca) end + before do + stub_const("OpenSSL::X509::DEFAULT_CERT_DIR", sample_ca_certs_path) + stub_const("OpenSSL::X509::DEFAULT_CERT_FILE", sample_cert) + described_class.reset_ca_certs_bundle + end + + after(:context) do + described_class.reset_ca_certs_bundle + end + describe 'testing environment setup' do describe 'generate_root' do subject { @root_ca } @@ -103,6 +116,43 @@ RSpec.describe Gitlab::X509::Certificate do end end + describe '.ca_certs_paths' do + it 'returns all files specified by OpenSSL defaults' do + cert_paths = Dir["#{OpenSSL::X509::DEFAULT_CERT_DIR}/*"] + + expect(described_class.ca_certs_paths).to match_array(cert_paths + [sample_cert]) + end + end + + describe '.ca_certs_bundle' do + it 'skips certificates if OpenSSLError is raised and report it' do + expect(Gitlab::ErrorTracking) + .to receive(:track_and_raise_for_dev_exception) + .with( + a_kind_of(OpenSSL::X509::CertificateError), + cert_file: a_kind_of(String)).at_least(:once) + + expect(OpenSSL::X509::Certificate) + .to receive(:new) + .and_raise(OpenSSL::X509::CertificateError).at_least(:once) + + expect(described_class.ca_certs_bundle).to be_a(String) + end + + it 'returns a list certificates as strings' do + expect(described_class.ca_certs_bundle).to be_a(String) + end + end + + describe '.load_ca_certs_bundle' do + it 'loads a PEM-encoded certificate bundle into an OpenSSL::X509::Certificate array' do + ca_certs_string = described_class.ca_certs_bundle + ca_certs = described_class.load_ca_certs_bundle(ca_certs_string) + + expect(ca_certs).to all(be_an(OpenSSL::X509::Certificate)) + end + end + def common_cert_tests(parsed_cert, cert, signer_ca, with_ca_certs: nil) expect(parsed_cert.cert).to be_a(OpenSSL::X509::Certificate) expect(parsed_cert.cert.subject).to eq(cert[:cert].subject) diff --git a/spec/lib/gitlab/x509/signature_spec.rb b/spec/lib/gitlab/x509/signature_spec.rb index 7ba15faf910..0e34d5393d6 100644 --- a/spec/lib/gitlab/x509/signature_spec.rb +++ b/spec/lib/gitlab/x509/signature_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Gitlab::X509::Signature do end shared_examples "a verified signature" do - let_it_be(:user) { create(:user, email: X509Helpers::User1.certificate_email) } + let!(:user) { create(:user, email: X509Helpers::User1.certificate_email) } subject(:signature) do described_class.new( @@ -30,10 +30,12 @@ RSpec.describe Gitlab::X509::Signature do expect(signature.verification_status).to eq(:verified) end - it "returns an unverified signature if the email matches but isn't confirmed" do - user.update!(confirmed_at: nil) + context "if the email matches but isn't confirmed" do + let!(:user) { create(:user, :unconfirmed, email: X509Helpers::User1.certificate_email) } - expect(signature.verification_status).to eq(:unverified) + it "returns an unverified signature" do + expect(signature.verification_status).to eq(:unverified) + end end it 'returns an unverified signature if email does not match' do @@ -297,7 +299,7 @@ RSpec.describe Gitlab::X509::Signature do end context 'verified signature' do - let_it_be(:user) { create(:user, email: X509Helpers::User1.certificate_email) } + let_it_be(:user) { create(:user, :unconfirmed, email: X509Helpers::User1.certificate_email) } subject(:signature) do described_class.new( @@ -316,52 +318,56 @@ RSpec.describe Gitlab::X509::Signature do allow(OpenSSL::X509::Store).to receive(:new).and_return(store) end - it 'returns a verified signature if email does match' do - expect(signature.x509_certificate).to have_attributes(certificate_attributes) - expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) - expect(signature.verified_signature).to be_truthy - expect(signature.verification_status).to eq(:verified) - end + context 'when user email is confirmed' do + before_all do + user.confirm + end - it "returns an unverified signature if the email matches but isn't confirmed" do - user.update!(confirmed_at: nil) + it 'returns a verified signature if email does match', :ggregate_failures do + expect(signature.x509_certificate).to have_attributes(certificate_attributes) + expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) + expect(signature.verified_signature).to be_truthy + expect(signature.verification_status).to eq(:verified) + end - expect(signature.verification_status).to eq(:unverified) - end + it 'returns an unverified signature if email does not match', :aggregate_failures do + signature = described_class.new( + X509Helpers::User1.signed_tag_signature, + X509Helpers::User1.signed_tag_base_data, + "gitlab@example.com", + X509Helpers::User1.signed_commit_time + ) + + expect(signature.x509_certificate).to have_attributes(certificate_attributes) + expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) + expect(signature.verified_signature).to be_truthy + expect(signature.verification_status).to eq(:unverified) + end - it 'returns an unverified signature if email does not match' do - signature = described_class.new( - X509Helpers::User1.signed_tag_signature, - X509Helpers::User1.signed_tag_base_data, - "gitlab@example.com", - X509Helpers::User1.signed_commit_time - ) + it 'returns an unverified signature if email does match and time is wrong', :aggregate_failures do + signature = described_class.new( + X509Helpers::User1.signed_tag_signature, + X509Helpers::User1.signed_tag_base_data, + X509Helpers::User1.certificate_email, + Time.new(2020, 2, 22) + ) + + expect(signature.x509_certificate).to have_attributes(certificate_attributes) + expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) + expect(signature.verified_signature).to be_falsey + expect(signature.verification_status).to eq(:unverified) + end - expect(signature.x509_certificate).to have_attributes(certificate_attributes) - expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) - expect(signature.verified_signature).to be_truthy - expect(signature.verification_status).to eq(:unverified) - end + it 'returns an unverified signature if certificate is revoked' do + expect(signature.verification_status).to eq(:verified) - it 'returns an unverified signature if email does match and time is wrong' do - signature = described_class.new( - X509Helpers::User1.signed_tag_signature, - X509Helpers::User1.signed_tag_base_data, - X509Helpers::User1.certificate_email, - Time.new(2020, 2, 22) - ) + signature.x509_certificate.revoked! - expect(signature.x509_certificate).to have_attributes(certificate_attributes) - expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) - expect(signature.verified_signature).to be_falsey - expect(signature.verification_status).to eq(:unverified) + expect(signature.verification_status).to eq(:unverified) + end end - it 'returns an unverified signature if certificate is revoked' do - expect(signature.verification_status).to eq(:verified) - - signature.x509_certificate.revoked! - + it 'returns an unverified signature if the email matches but is not confirmed' do expect(signature.verification_status).to eq(:unverified) end end diff --git a/spec/lib/gitlab/zentao/client_spec.rb b/spec/lib/gitlab/zentao/client_spec.rb index e3a335c1e89..86b310fe417 100644 --- a/spec/lib/gitlab/zentao/client_spec.rb +++ b/spec/lib/gitlab/zentao/client_spec.rb @@ -6,7 +6,23 @@ RSpec.describe Gitlab::Zentao::Client do subject(:integration) { described_class.new(zentao_integration) } let(:zentao_integration) { create(:zentao_integration) } - let(:mock_get_products_url) { integration.send(:url, "products/#{zentao_integration.zentao_product_xid}") } + + def mock_get_products_url + integration.send(:url, "products/#{zentao_integration.zentao_product_xid}") + end + + def mock_fetch_issue_url(issue_id) + integration.send(:url, "issues/#{issue_id}") + end + + let(:mock_headers) do + { + headers: { + 'Content-Type' => 'application/json', + 'Token' => zentao_integration.api_token + } + } + end describe '#new' do context 'if integration is nil' do @@ -25,15 +41,6 @@ RSpec.describe Gitlab::Zentao::Client do end describe '#fetch_product' do - let(:mock_headers) do - { - headers: { - 'Content-Type' => 'application/json', - 'Token' => zentao_integration.api_token - } - } - end - context 'with valid product' do let(:mock_response) { { 'id' => zentao_integration.zentao_product_xid } } @@ -54,7 +61,9 @@ RSpec.describe Gitlab::Zentao::Client do end it 'fetches the empty product' do - expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({}) + expect do + integration.fetch_product(zentao_integration.zentao_product_xid) + end.to raise_error(Gitlab::Zentao::Client::Error, 'request error') end end @@ -65,21 +74,14 @@ RSpec.describe Gitlab::Zentao::Client do end it 'fetches the empty product' do - expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({}) + expect do + integration.fetch_product(zentao_integration.zentao_product_xid) + end.to raise_error(Gitlab::Zentao::Client::Error, 'invalid response format') end end end describe '#ping' do - let(:mock_headers) do - { - headers: { - 'Content-Type' => 'application/json', - 'Token' => zentao_integration.api_token - } - } - end - context 'with valid resource' do before do WebMock.stub_request(:get, mock_get_products_url) @@ -102,4 +104,30 @@ RSpec.describe Gitlab::Zentao::Client do end end end + + describe '#fetch_issue' do + context 'with invalid id' do + let(:invalid_ids) { ['story', 'story-', '-', '123', ''] } + + it 'returns empty object' do + invalid_ids.each do |id| + expect { integration.fetch_issue(id) } + .to raise_error(Gitlab::Zentao::Client::Error, 'invalid issue id') + end + end + end + + context 'with valid id' do + let(:valid_ids) { %w[story-1 bug-23] } + + it 'fetches current issue' do + valid_ids.each do |id| + WebMock.stub_request(:get, mock_fetch_issue_url(id)) + .with(mock_headers).to_return(status: 200, body: { issue: { id: id } }.to_json) + + expect(integration.fetch_issue(id).dig('issue', 'id')).to eq id + end + end + end + end end diff --git a/spec/lib/gitlab/zentao/query_spec.rb b/spec/lib/gitlab/zentao/query_spec.rb new file mode 100644 index 00000000000..f7495e640c3 --- /dev/null +++ b/spec/lib/gitlab/zentao/query_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Zentao::Query do + let(:zentao_integration) { create(:zentao_integration) } + let(:params) { {} } + + subject(:query) { described_class.new(zentao_integration, ActionController::Parameters.new(params)) } + + describe '#issues' do + let(:response) { { 'page' => 1, 'total' => 0, 'limit' => 20, 'issues' => [] } } + + def expect_query_option_include(expected_params) + expect_next_instance_of(Gitlab::Zentao::Client) do |client| + expect(client).to receive(:fetch_issues) + .with(hash_including(expected_params)) + .and_return(response) + end + + query.issues + end + + context 'when params are empty' do + it 'fills default params' do + expect_query_option_include(status: 'opened', order: 'lastEditedDate_desc', labels: '') + end + end + + context 'when params contain valid options' do + let(:params) { { state: 'closed', sort: 'created_asc', labels: %w[Bugs Features] } } + + it 'fills params with standard of ZenTao' do + expect_query_option_include(status: 'closed', order: 'openedDate_asc', labels: 'Bugs,Features') + end + end + + context 'when params contain invalid options' do + let(:params) { { state: 'xxx', sort: 'xxx', labels: %w[xxx] } } + + it 'fills default params with standard of ZenTao' do + expect_query_option_include(status: 'opened', order: 'lastEditedDate_desc', labels: 'xxx') + end + end + end + + describe '#issue' do + let(:response) { { 'issue' => { 'id' => 'story-1' } } } + + before do + expect_next_instance_of(Gitlab::Zentao::Client) do |client| + expect(client).to receive(:fetch_issue) + .and_return(response) + end + end + + it 'returns issue object by client' do + expect(query.issue).to include('id' => 'story-1') + end + end +end diff --git a/spec/lib/marginalia_spec.rb b/spec/lib/marginalia_spec.rb index 3f39d969dbd..53048ae2e6b 100644 --- a/spec/lib/marginalia_spec.rb +++ b/spec/lib/marginalia_spec.rb @@ -59,14 +59,14 @@ RSpec.describe 'Marginalia spec' do "application" => "test", "endpoint_id" => "MarginaliaTestController#first_user", "correlation_id" => correlation_id, - "db_config_name" => "ci" + "db_config_name" => ENV['GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci'] == 'main' ? 'main' : 'ci' } end - before do |example| + before do skip_if_multiple_databases_not_setup - allow(User).to receive(:connection) { Ci::CiDatabaseRecord.connection } + allow(User).to receive(:connection) { Ci::ApplicationRecord.connection } end it 'generates a query that includes the component and value' do diff --git a/spec/lib/object_storage/config_spec.rb b/spec/lib/object_storage/config_spec.rb index 21b8a44b3d6..9a0e83bfd5e 100644 --- a/spec/lib/object_storage/config_spec.rb +++ b/spec/lib/object_storage/config_spec.rb @@ -36,46 +36,6 @@ RSpec.describe ObjectStorage::Config do subject { described_class.new(raw_config.as_json) } - describe '#load_provider' do - before do - subject.load_provider - end - - context 'with AWS' do - it 'registers AWS as a provider' do - expect(Fog.providers.keys).to include(:aws) - end - end - - context 'with Google' do - let(:credentials) do - { - provider: 'Google', - google_storage_access_key_id: 'GOOGLE_ACCESS_KEY_ID', - google_storage_secret_access_key: 'GOOGLE_SECRET_ACCESS_KEY' - } - end - - it 'registers Google as a provider' do - expect(Fog.providers.keys).to include(:google) - end - end - - context 'with Azure' do - let(:credentials) do - { - provider: 'AzureRM', - azure_storage_account_name: 'azuretest', - azure_storage_access_key: 'ABCD1234' - } - end - - it 'registers AzureRM as a provider' do - expect(Fog.providers.keys).to include(:azurerm) - end - end - end - describe '#credentials' do it { expect(subject.credentials).to eq(credentials) } end diff --git a/spec/lib/object_storage/direct_upload_spec.rb b/spec/lib/object_storage/direct_upload_spec.rb index 006f4f603b6..1629aec89f5 100644 --- a/spec/lib/object_storage/direct_upload_spec.rb +++ b/spec/lib/object_storage/direct_upload_spec.rb @@ -201,10 +201,6 @@ RSpec.describe ObjectStorage::DirectUpload do end shared_examples 'a valid AzureRM upload' do - before do - require 'fog/azurerm' - end - it_behaves_like 'a valid upload' it 'enables the Workhorse client' do diff --git a/spec/lib/security/ci_configuration/sast_iac_build_action_spec.rb b/spec/lib/security/ci_configuration/sast_iac_build_action_spec.rb new file mode 100644 index 00000000000..ecd1602dd9e --- /dev/null +++ b/spec/lib/security/ci_configuration/sast_iac_build_action_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::CiConfiguration::SastIacBuildAction do + subject(:result) { described_class.new(auto_devops_enabled, gitlab_ci_content).generate } + + let(:params) { {} } + + context 'with existing .gitlab-ci.yml' do + let(:auto_devops_enabled) { false } + + context 'sast iac has not been included' do + let(:expected_yml) do + <<-CI_YML.strip_heredoc + # You can override the included template(s) by including variable overrides + # SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings + # Secret Detection customization: https://docs.gitlab.com/ee/user/application_security/secret_detection/#customizing-settings + # Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings + # Note that environment variables can be set in several places + # See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence + stages: + - test + - security + variables: + RANDOM: make sure this persists + include: + - template: existing.yml + - template: Security/SAST-IaC.latest.gitlab-ci.yml + CI_YML + end + + context 'template includes are an array' do + let(:gitlab_ci_content) do + { "stages" => %w(test security), + "variables" => { "RANDOM" => "make sure this persists" }, + "include" => [{ "template" => "existing.yml" }] } + end + + it 'generates the correct YML' do + expect(result[:action]).to eq('update') + expect(result[:content]).to eq(expected_yml) + end + end + + context 'template include is not an array' do + let(:gitlab_ci_content) do + { "stages" => %w(test security), + "variables" => { "RANDOM" => "make sure this persists" }, + "include" => { "template" => "existing.yml" } } + end + + it 'generates the correct YML' do + expect(result[:action]).to eq('update') + expect(result[:content]).to eq(expected_yml) + end + end + end + + context 'secret_detection has been included' do + let(:expected_yml) do + <<-CI_YML.strip_heredoc + # You can override the included template(s) by including variable overrides + # SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings + # Secret Detection customization: https://docs.gitlab.com/ee/user/application_security/secret_detection/#customizing-settings + # Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings + # Note that environment variables can be set in several places + # See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence + stages: + - test + variables: + RANDOM: make sure this persists + include: + - template: Security/SAST-IaC.latest.gitlab-ci.yml + CI_YML + end + + context 'secret_detection template include are an array' do + let(:gitlab_ci_content) do + { "stages" => %w(test), + "variables" => { "RANDOM" => "make sure this persists" }, + "include" => [{ "template" => "Security/SAST-IaC.latest.gitlab-ci.yml" }] } + end + + it 'generates the correct YML' do + expect(result[:action]).to eq('update') + expect(result[:content]).to eq(expected_yml) + end + end + + context 'secret_detection template include is not an array' do + let(:gitlab_ci_content) do + { "stages" => %w(test), + "variables" => { "RANDOM" => "make sure this persists" }, + "include" => { "template" => "Security/SAST-IaC.latest.gitlab-ci.yml" } } + end + + it 'generates the correct YML' do + expect(result[:action]).to eq('update') + expect(result[:content]).to eq(expected_yml) + end + end + end + end + + context 'with no .gitlab-ci.yml' do + let(:gitlab_ci_content) { nil } + + context 'autodevops disabled' do + let(:auto_devops_enabled) { false } + let(:expected_yml) do + <<-CI_YML.strip_heredoc + # You can override the included template(s) by including variable overrides + # SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings + # Secret Detection customization: https://docs.gitlab.com/ee/user/application_security/secret_detection/#customizing-settings + # Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings + # Note that environment variables can be set in several places + # See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence + include: + - template: Security/SAST-IaC.latest.gitlab-ci.yml + CI_YML + end + + it 'generates the correct YML' do + expect(result[:action]).to eq('create') + expect(result[:content]).to eq(expected_yml) + end + end + + context 'with autodevops enabled' do + let(:auto_devops_enabled) { true } + let(:expected_yml) do + <<-CI_YML.strip_heredoc + # You can override the included template(s) by including variable overrides + # SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings + # Secret Detection customization: https://docs.gitlab.com/ee/user/application_security/secret_detection/#customizing-settings + # Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings + # Note that environment variables can be set in several places + # See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence + include: + - template: Auto-DevOps.gitlab-ci.yml + CI_YML + end + + before do + allow_next_instance_of(described_class) do |sast_iac_build_actions| + allow(sast_iac_build_actions).to receive(:auto_devops_stages).and_return(fast_auto_devops_stages) + end + end + + it 'generates the correct YML' do + expect(result[:action]).to eq('create') + expect(result[:content]).to eq(expected_yml) + end + end + end + + # stubbing this method allows this spec file to use fast_spec_helper + def fast_auto_devops_stages + auto_devops_template = YAML.safe_load( File.read('lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml') ) + auto_devops_template['stages'] + end +end diff --git a/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb b/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb new file mode 100644 index 00000000000..a79e5182f45 --- /dev/null +++ b/spec/lib/sidebars/groups/menus/invite_team_members_menu_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::Groups::Menus::InviteTeamMembersMenu do + let_it_be(:owner) { create(:user) } + let_it_be(:guest) { create(:user) } + let_it_be(:group) do + build(:group).tap do |g| + g.add_owner(owner) + end + end + + let(:context) { Sidebars::Groups::Context.new(current_user: owner, container: group) } + + subject(:invite_menu) { described_class.new(context) } + + context 'when the group is viewed by an owner of the group' do + describe '#render?' do + it 'renders the Invite team members link' do + expect(invite_menu.render?).to eq(true) + end + + context 'when the group already has at least 2 members' do + before do + group.add_guest(guest) + end + + it 'does not render the link' do + expect(invite_menu.render?).to eq(false) + end + end + end + + describe '#title' do + it 'displays the correct Invite team members text for the link in the side nav' do + expect(invite_menu.title).to eq('Invite members') + end + end + end + + context 'when the group is viewed by a guest user without admin permissions' do + let(:context) { Sidebars::Groups::Context.new(current_user: guest, container: group) } + + before do + group.add_guest(guest) + end + + describe '#render?' do + it 'does not render the link' do + expect(subject.render?).to eq(false) + end + end + end +end diff --git a/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb b/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb index 5ebd67462f8..e954d7a44ba 100644 --- a/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb +++ b/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb @@ -137,16 +137,27 @@ RSpec.describe Sidebars::Groups::Menus::PackagesRegistriesMenu do stub_config(dependency_proxy: { enabled: dependency_enabled }) end - context 'when config dependency_proxy is enabled' do - let(:dependency_enabled) { true } + context 'when user can read dependency proxy' do + context 'when config dependency_proxy is enabled' do + let(:dependency_enabled) { true } - it 'the menu item is added to list of menu items' do - is_expected.not_to be_nil + it 'the menu item is added to list of menu items' do + is_expected.not_to be_nil + end + end + + context 'when config dependency_proxy is not enabled' do + let(:dependency_enabled) { false } + + it 'the menu item is not added to list of menu items' do + is_expected.to be_nil + end end end - context 'when config dependency_proxy is not enabled' do - let(:dependency_enabled) { false } + context 'when user cannot read dependency proxy' do + let(:user) { nil } + let(:dependency_enabled) { true } it 'the menu item is not added to list of menu items' do is_expected.to be_nil diff --git a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb index 2415598da9c..55281171634 100644 --- a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb @@ -51,6 +51,16 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do it 'menu link points to Terraform page' do expect(subject.link).to eq find_menu_item(:terraform).link end + + context 'when Terraform menu is not visible' do + before do + subject.renderable_items.delete(find_menu_item(:terraform)) + end + + it 'menu link points to Google Cloud page' do + expect(subject.link).to eq find_menu_item(:google_cloud).link + end + end end end @@ -89,5 +99,11 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do it_behaves_like 'access rights checks' end + + describe 'Google Cloud' do + let(:item_id) { :google_cloud } + + it_behaves_like 'access rights checks' + end end end diff --git a/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb b/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb new file mode 100644 index 00000000000..df9b260d211 --- /dev/null +++ b/spec/lib/sidebars/projects/menus/invite_team_members_menu_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::Projects::Menus::InviteTeamMembersMenu do + let_it_be(:project) { create(:project) } + let_it_be(:guest) { create(:user) } + + let(:context) { Sidebars::Projects::Context.new(current_user: owner, container: project) } + + subject(:invite_menu) { described_class.new(context) } + + context 'when the project is viewed by an owner of the group' do + let(:owner) { project.owner } + + describe '#render?' do + it 'renders the Invite team members link' do + expect(invite_menu.render?).to eq(true) + end + + context 'when the project already has at least 2 members' do + before do + project.add_guest(guest) + end + + it 'does not render the link' do + expect(invite_menu.render?).to eq(false) + end + end + end + + describe '#title' do + it 'displays the correct Invite team members text for the link in the side nav' do + expect(invite_menu.title).to eq('Invite members') + end + end + end + + context 'when the project is viewed by a guest user without admin permissions' do + let(:context) { Sidebars::Projects::Context.new(current_user: guest, container: project) } + + before do + project.add_guest(guest) + end + + describe '#render?' do + it 'does not render' do + expect(invite_menu.render?).to eq(false) + end + end + end +end diff --git a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb index 3079c781d73..1e5d41dfec4 100644 --- a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb @@ -162,24 +162,10 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do describe 'Usage Quotas' do let(:item_id) { :usage_quotas } - describe 'with project_storage_ui feature flag enabled' do - before do - stub_feature_flags(project_storage_ui: true) - end - - specify { is_expected.not_to be_nil } - - describe 'when the user does not have access' do - let(:user) { nil } - - specify { is_expected.to be_nil } - end - end + specify { is_expected.not_to be_nil } - describe 'with project_storage_ui feature flag disabled' do - before do - stub_feature_flags(project_storage_ui: false) - end + describe 'when the user does not have access' do + let(:user) { nil } specify { is_expected.to be_nil } end diff --git a/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb b/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb new file mode 100644 index 00000000000..f0bce6b7ea5 --- /dev/null +++ b/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::Projects::Menus::ZentaoMenu do + it_behaves_like 'ZenTao menu with CE version' +end diff --git a/spec/lib/system_check/incoming_email_check_spec.rb b/spec/lib/system_check/incoming_email_check_spec.rb index 710702b93fc..5d93b810045 100644 --- a/spec/lib/system_check/incoming_email_check_spec.rb +++ b/spec/lib/system_check/incoming_email_check_spec.rb @@ -28,7 +28,7 @@ RSpec.describe SystemCheck::IncomingEmailCheck do it 'runs IMAP and mailroom checks' do expect(SystemCheck).to receive(:run).with('Reply by email', [ SystemCheck::IncomingEmail::ImapAuthenticationCheck, - SystemCheck::IncomingEmail::InitdConfiguredCheck, + SystemCheck::IncomingEmail::MailRoomEnabledCheck, SystemCheck::IncomingEmail::MailRoomRunningCheck ]) @@ -43,7 +43,7 @@ RSpec.describe SystemCheck::IncomingEmailCheck do it 'runs mailroom checks' do expect(SystemCheck).to receive(:run).with('Reply by email', [ - SystemCheck::IncomingEmail::InitdConfiguredCheck, + SystemCheck::IncomingEmail::MailRoomEnabledCheck, SystemCheck::IncomingEmail::MailRoomRunningCheck ]) diff --git a/spec/lib/uploaded_file_spec.rb b/spec/lib/uploaded_file_spec.rb index ececc84bc93..0aba6cb0065 100644 --- a/spec/lib/uploaded_file_spec.rb +++ b/spec/lib/uploaded_file_spec.rb @@ -15,7 +15,7 @@ RSpec.describe UploadedFile do end context 'from_params functions' do - RSpec.shared_examples 'using the file path' do |filename:, content_type:, sha256:, path_suffix:| + RSpec.shared_examples 'using the file path' do |filename:, content_type:, sha256:, path_suffix:, upload_duration:| it { is_expected.not_to be_nil } it 'sets properly the attributes' do @@ -24,6 +24,7 @@ RSpec.describe UploadedFile do expect(subject.sha256).to eq(sha256) expect(subject.remote_id).to be_nil expect(subject.path).to end_with(path_suffix) + expect(subject.upload_duration).to eq(upload_duration) end it 'handles a blank path' do @@ -37,16 +38,17 @@ RSpec.describe UploadedFile do end end - RSpec.shared_examples 'using the remote id' do |filename:, content_type:, sha256:, size:, remote_id:| + RSpec.shared_examples 'using the remote id' do |filename:, content_type:, sha256:, size:, remote_id:, upload_duration:| it { is_expected.not_to be_nil } it 'sets properly the attributes' do expect(subject.original_filename).to eq(filename) - expect(subject.content_type).to eq('application/octet-stream') - expect(subject.sha256).to eq('sha256') + expect(subject.content_type).to eq(content_type) + expect(subject.sha256).to eq(sha256) expect(subject.path).to be_nil - expect(subject.size).to eq(123456) - expect(subject.remote_id).to eq('1234567890') + expect(subject.size).to eq(size) + expect(subject.remote_id).to eq(remote_id) + expect(subject.upload_duration).to eq(upload_duration) end end @@ -78,6 +80,7 @@ RSpec.describe UploadedFile do { 'path' => temp_file.path, 'name' => 'dir/my file&.txt', 'type' => 'my/type', + 'upload_duration' => '5.05', 'sha256' => 'sha256' } end @@ -85,7 +88,8 @@ RSpec.describe UploadedFile do filename: 'my_file_.txt', content_type: 'my/type', sha256: 'sha256', - path_suffix: 'test' + path_suffix: 'test', + upload_duration: 5.05 end context 'with a remote id' do @@ -96,6 +100,7 @@ RSpec.describe UploadedFile do 'remote_url' => 'http://localhost/file', 'remote_id' => '1234567890', 'etag' => 'etag1234567890', + 'upload_duration' => '5.05', 'size' => '123456' } end @@ -105,7 +110,8 @@ RSpec.describe UploadedFile do content_type: 'application/octet-stream', sha256: 'sha256', size: 123456, - remote_id: '1234567890' + remote_id: '1234567890', + upload_duration: 5.05 end context 'with a path and a remote id' do @@ -117,6 +123,7 @@ RSpec.describe UploadedFile do 'remote_url' => 'http://localhost/file', 'remote_id' => '1234567890', 'etag' => 'etag1234567890', + 'upload_duration' => '5.05', 'size' => '123456' } end @@ -126,7 +133,8 @@ RSpec.describe UploadedFile do content_type: 'application/octet-stream', sha256: 'sha256', size: 123456, - remote_id: '1234567890' + remote_id: '1234567890', + upload_duration: 5.05 end end end @@ -216,6 +224,44 @@ RSpec.describe UploadedFile do end.to raise_error(UploadedFile::UnknownSizeError, 'Unable to determine file size') end end + + context 'when upload_duration is not provided' do + it 'sets upload_duration to zero' do + file = described_class.new(temp_file.path) + + expect(file.upload_duration).to be_zero + end + end + + context 'when upload_duration is provided' do + let(:file) { described_class.new(temp_file.path, upload_duration: duration) } + + context 'and upload_duration is a number' do + let(:duration) { 5.505 } + + it 'sets the upload_duration' do + expect(file.upload_duration).to eq(duration) + end + end + + context 'and upload_duration is a string' do + context 'and represents a number' do + let(:duration) { '5.505' } + + it 'converts upload_duration to a number' do + expect(file.upload_duration).to eq(duration.to_f) + end + end + + context 'and does not represent a number' do + let(:duration) { 'not a number' } + + it 'sets upload_duration to zero' do + expect(file.upload_duration).to be_zero + end + end + end + end end describe '#sanitize_filename' do diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb index 99beef92dea..3b92b049e42 100644 --- a/spec/mailers/emails/in_product_marketing_spec.rb +++ b/spec/mailers/emails/in_product_marketing_spec.rb @@ -47,22 +47,31 @@ RSpec.describe Emails::InProductMarketing do end where(:track, :series) do - :create | 0 - :create | 1 - :create | 2 - :verify | 0 - :verify | 1 - :verify | 2 - :trial | 0 - :trial | 1 - :trial | 2 - :team | 0 - :team | 1 - :team | 2 - :experience | 0 + :create | 0 + :create | 1 + :create | 2 + :verify | 0 + :verify | 1 + :verify | 2 + :trial | 0 + :trial | 1 + :trial | 2 + :team | 0 + :team | 1 + :team | 2 + :experience | 0 + :team_short | 0 + :trial_short | 0 + :admin_verify | 0 + :invite_team | 0 end with_them do + before do + stub_experiments(invite_members_for_task: :candidate) + group.add_owner(user) + end + it 'has the correct subject and content' do message = Gitlab::Email::Message::InProductMarketing.for(track).new(group: group, user: user, series: series) @@ -76,6 +85,20 @@ RSpec.describe Emails::InProductMarketing do else is_expected.to have_body_text(CGI.unescapeHTML(message.cta_link)) end + + if track =~ /(create|verify)/ + is_expected.to have_body_text(message.invite_text) + is_expected.to have_body_text(CGI.unescapeHTML(message.invite_link)) + else + is_expected.not_to have_body_text(message.invite_text) + is_expected.not_to have_body_text(CGI.unescapeHTML(message.invite_link)) + end + + if track == :invite_team + is_expected.not_to have_body_text(/This is email \d of \d/) + else + is_expected.to have_body_text(message.progress) + end end end end diff --git a/spec/mailers/emails/pipelines_spec.rb b/spec/mailers/emails/pipelines_spec.rb index b9bc53625ac..3a2eb105964 100644 --- a/spec/mailers/emails/pipelines_spec.rb +++ b/spec/mailers/emails/pipelines_spec.rb @@ -71,10 +71,19 @@ RSpec.describe Emails::Pipelines do end end + shared_examples_for 'only accepts a single recipient' do + let(:recipient) { ['test@gitlab.com', 'test2@gitlab.com'] } + + it 'raises an ArgumentError' do + expect { subject.deliver_now }.to raise_error(ArgumentError) + end + end + describe '#pipeline_success_email' do - subject { Notify.pipeline_success_email(pipeline, pipeline.user.try(:email)) } + subject { Notify.pipeline_success_email(pipeline, recipient) } let(:pipeline) { create(:ci_pipeline, project: project, ref: ref, sha: sha) } + let(:recipient) { pipeline.user.try(:email) } let(:ref) { 'master' } let(:sha) { project.commit(ref).sha } @@ -93,12 +102,15 @@ RSpec.describe Emails::Pipelines do stub_config_setting(email_subject_suffix: email_subject_suffix) end end + + it_behaves_like 'only accepts a single recipient' end describe '#pipeline_failed_email' do - subject { Notify.pipeline_failed_email(pipeline, pipeline.user.try(:email)) } + subject { Notify.pipeline_failed_email(pipeline, recipient) } let(:pipeline) { create(:ci_pipeline, project: project, ref: ref, sha: sha) } + let(:recipient) { pipeline.user.try(:email) } let(:ref) { 'master' } let(:sha) { project.commit(ref).sha } @@ -106,12 +118,15 @@ RSpec.describe Emails::Pipelines do let(:status) { 'Failed' } let(:status_text) { "Pipeline ##{pipeline.id} has failed!" } end + + it_behaves_like 'only accepts a single recipient' end describe '#pipeline_fixed_email' do subject { Notify.pipeline_fixed_email(pipeline, pipeline.user.try(:email)) } let(:pipeline) { create(:ci_pipeline, project: project, ref: ref, sha: sha) } + let(:recipient) { pipeline.user.try(:email) } let(:ref) { 'master' } let(:sha) { project.commit(ref).sha } @@ -119,5 +134,7 @@ RSpec.describe Emails::Pipelines do let(:status) { 'Fixed' } let(:status_text) { "Pipeline has been fixed and ##{pipeline.id} has passed!" } end + + it_behaves_like 'only accepts a single recipient' end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index f39037cf744..a5e3350ec2e 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Notify do include EmailSpec::Matchers include EmailHelpers include RepoHelpers + include MembersHelper include_context 'gitlab email notification' @@ -720,11 +721,8 @@ RSpec.describe Notify do end describe 'project access denied' do - let(:project) { create(:project, :public) } - let(:project_member) do - project.request_access(user) - project.requesters.find_by(user_id: user.id) - end + let_it_be(:project) { create(:project, :public) } + let_it_be(:project_member) { create(:project_member, :developer, :access_request, user: user, source: project) } subject { described_class.member_access_denied_email('project', project.id, user.id) } @@ -739,6 +737,17 @@ RSpec.describe Notify do is_expected.to have_body_text project.full_name is_expected.to have_body_text project.web_url end + + context 'when user can not read project' do + let_it_be(:project) { create(:project, :private) } + + it 'hides project name from subject and body' do + is_expected.to have_subject "Access to the Hidden project was denied" + is_expected.to have_body_text "Hidden project" + is_expected.not_to have_body_text project.full_name + is_expected.not_to have_body_text project.web_url + end + end end describe 'project access changed' do @@ -761,10 +770,21 @@ RSpec.describe Notify do is_expected.to have_body_text project_member.human_access is_expected.to have_body_text 'leave the project' is_expected.to have_body_text project_url(project, leave: 1) + is_expected.not_to have_body_text 'You were assigned the following tasks:' + end + + context 'with tasks to be done present' do + let(:project_member) { create(:project_member, project: project, user: user, tasks_to_be_done: [:ci, :code]) } + + it 'contains the assigned tasks to be done' do + is_expected.to have_body_text 'You were assigned the following tasks:' + is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci] + is_expected.to have_body_text localized_tasks_to_be_done_choices[:code] + end end end - def invite_to_project(project, inviter:, user: nil) + def invite_to_project(project, inviter:, user: nil, tasks_to_be_done: []) create( :project_member, :developer, @@ -772,7 +792,8 @@ RSpec.describe Notify do invite_token: '1234', invite_email: 'toto@example.com', user: user, - created_by: inviter + created_by: inviter, + tasks_to_be_done: tasks_to_be_done ) end @@ -804,6 +825,7 @@ RSpec.describe Notify do is_expected.to have_content("#{inviter.name} invited you to join the") is_expected.to have_content('Project details') is_expected.to have_content("What's it about?") + is_expected.not_to have_body_text 'and has assigned you the following tasks:' end end @@ -890,6 +912,16 @@ RSpec.describe Notify do end end end + + context 'with tasks to be done present', :aggregate_failures do + let(:project_member) { invite_to_project(project, inviter: inviter, tasks_to_be_done: [:ci, :code]) } + + it 'contains the assigned tasks to be done' do + is_expected.to have_body_text 'and has assigned you the following tasks:' + is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci] + is_expected.to have_body_text localized_tasks_to_be_done_choices[:code] + end + end end describe 'project invitation accepted' do @@ -1351,10 +1383,8 @@ RSpec.describe Notify do end describe 'group access denied' do - let(:group_member) do - group.request_access(user) - group.requesters.find_by(user_id: user.id) - end + let_it_be(:group) { create(:group, :public) } + let_it_be(:group_member) { create(:group_member, :developer, :access_request, user: user, source: group) } let(:recipient) { user } @@ -1372,6 +1402,17 @@ RSpec.describe Notify do is_expected.to have_body_text group.name is_expected.to have_body_text group.web_url end + + context 'when user can not read group' do + let_it_be(:group) { create(:group, :private) } + + it 'hides group name from subject and body' do + is_expected.to have_subject "Access to the Hidden group was denied" + is_expected.to have_body_text "Hidden group" + is_expected.not_to have_body_text group.name + is_expected.not_to have_body_text group.web_url + end + end end describe 'group access changed' do @@ -1398,7 +1439,7 @@ RSpec.describe Notify do end end - def invite_to_group(group, inviter:, user: nil) + def invite_to_group(group, inviter:, user: nil, tasks_to_be_done: []) create( :group_member, :developer, @@ -1406,7 +1447,8 @@ RSpec.describe Notify do invite_token: '1234', invite_email: 'toto@example.com', user: user, - created_by: inviter + created_by: inviter, + tasks_to_be_done: tasks_to_be_done ) end @@ -1431,6 +1473,7 @@ RSpec.describe Notify do is_expected.to have_body_text group.name is_expected.to have_body_text group_member.human_access.downcase is_expected.to have_body_text group_member.invite_token + is_expected.not_to have_body_text 'and has assigned you the following tasks:' end end @@ -1444,6 +1487,24 @@ RSpec.describe Notify do is_expected.to have_body_text group_member.invite_token end end + + context 'with tasks to be done present', :aggregate_failures do + let(:group_member) { invite_to_group(group, inviter: inviter, tasks_to_be_done: [:ci, :code]) } + + it 'contains the assigned tasks to be done' do + is_expected.to have_body_text 'and has assigned you the following tasks:' + is_expected.to have_body_text localized_tasks_to_be_done_choices[:ci] + is_expected.to have_body_text localized_tasks_to_be_done_choices[:code] + end + + context 'when there is no inviter' do + let(:inviter) { nil } + + it 'does not contain the assigned tasks to be done' do + is_expected.not_to have_body_text 'and has assigned you the following tasks:' + end + end + end end describe 'group invitation reminders' do diff --git a/spec/migrations/20200107172020_add_timestamp_softwarelicensespolicy_spec.rb b/spec/migrations/20200107172020_add_timestamp_softwarelicensespolicy_spec.rb deleted file mode 100644 index fff0745e8af..00000000000 --- a/spec/migrations/20200107172020_add_timestamp_softwarelicensespolicy_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('add_timestamp_softwarelicensespolicy') - -RSpec.describe AddTimestampSoftwarelicensespolicy do - let(:software_licenses_policy) { table(:software_license_policies) } - let(:projects) { table(:projects) } - let(:licenses) { table(:software_licenses) } - - before do - projects.create!(name: 'gitlab', path: 'gitlab-org/gitlab-ce', namespace_id: 1) - licenses.create!(name: 'MIT') - software_licenses_policy.create!(project_id: projects.first.id, software_license_id: licenses.first.id) - end - - it 'creates timestamps' do - migrate! - - expect(software_licenses_policy.first.created_at).to be_nil - expect(software_licenses_policy.first.updated_at).to be_nil - end -end diff --git a/spec/migrations/20200122123016_backfill_project_settings_spec.rb b/spec/migrations/20200122123016_backfill_project_settings_spec.rb deleted file mode 100644 index 7fc8eb0e368..00000000000 --- a/spec/migrations/20200122123016_backfill_project_settings_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('backfill_project_settings') - -RSpec.describe BackfillProjectSettings, :sidekiq, schema: 20200114113341 do - let(:projects) { table(:projects) } - let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } - let(:project) { projects.create!(namespace_id: namespace.id) } - - describe '#up' do - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - - projects.create!(id: 1, namespace_id: namespace.id) - projects.create!(id: 2, namespace_id: namespace.id) - projects.create!(id: 3, namespace_id: namespace.id) - end - - it 'schedules BackfillProjectSettings background jobs' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 1, 2) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 3, 3) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end -end diff --git a/spec/migrations/20200123155929_remove_invalid_jira_data_spec.rb b/spec/migrations/20200123155929_remove_invalid_jira_data_spec.rb deleted file mode 100644 index 9000d4b7fef..00000000000 --- a/spec/migrations/20200123155929_remove_invalid_jira_data_spec.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('remove_invalid_jira_data') - -RSpec.describe RemoveInvalidJiraData do - let(:jira_tracker_data) { table(:jira_tracker_data) } - let(:services) { table(:services) } - - let(:service) { services.create!(id: 1) } - let(:data) do - { - service_id: service.id, - encrypted_api_url: 'http:url.com', - encrypted_api_url_iv: 'somevalue', - encrypted_url: 'http:url.com', - encrypted_url_iv: 'somevalue', - encrypted_username: 'username', - encrypted_username_iv: 'somevalue', - encrypted_password: 'username', - encrypted_password_iv: 'somevalue' - } - end - - let!(:valid_data) { jira_tracker_data.create!(data) } - let!(:empty_data) { jira_tracker_data.create!(service_id: service.id) } - let!(:invalid_api_url) do - data[:encrypted_api_url_iv] = nil - jira_tracker_data.create!(data) - end - - let!(:missing_api_url) do - data[:encrypted_api_url] = '' - data[:encrypted_api_url_iv] = nil - jira_tracker_data.create!(data) - end - - let!(:invalid_url) do - data[:encrypted_url_iv] = nil - jira_tracker_data.create!(data) - end - - let!(:missing_url) do - data[:encrypted_url] = '' - jira_tracker_data.create!(data) - end - - let!(:invalid_username) do - data[:encrypted_username_iv] = nil - jira_tracker_data.create!(data) - end - - let!(:missing_username) do - data[:encrypted_username] = nil - data[:encrypted_username_iv] = nil - jira_tracker_data.create!(data) - end - - let!(:invalid_password) do - data[:encrypted_password_iv] = nil - jira_tracker_data.create!(data) - end - - let!(:missing_password) do - data[:encrypted_password] = nil - data[:encrypted_username_iv] = nil - jira_tracker_data.create!(data) - end - - it 'removes the invalid data' do - valid_data_records = [valid_data, empty_data, missing_api_url, missing_url, missing_username, missing_password] - - expect { migrate! }.to change { jira_tracker_data.count }.from(10).to(6) - - expect(jira_tracker_data.all).to match_array(valid_data_records) - end -end diff --git a/spec/migrations/20200127090233_remove_invalid_issue_tracker_data_spec.rb b/spec/migrations/20200127090233_remove_invalid_issue_tracker_data_spec.rb deleted file mode 100644 index 1d3476d6d61..00000000000 --- a/spec/migrations/20200127090233_remove_invalid_issue_tracker_data_spec.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('remove_invalid_issue_tracker_data') - -RSpec.describe RemoveInvalidIssueTrackerData do - let(:issue_tracker_data) { table(:issue_tracker_data) } - let(:services) { table(:services) } - - let(:service) { services.create!(id: 1) } - let(:data) do - { - service_id: service.id, - encrypted_issues_url: 'http:url.com', - encrypted_issues_url_iv: 'somevalue', - encrypted_new_issue_url: 'http:url.com', - encrypted_new_issue_url_iv: 'somevalue', - encrypted_project_url: 'username', - encrypted_project_url_iv: 'somevalue' - } - end - - let!(:valid_data) { issue_tracker_data.create!(data) } - let!(:empty_data) { issue_tracker_data.create!(service_id: service.id) } - let!(:invalid_issues_url) do - data[:encrypted_issues_url_iv] = nil - issue_tracker_data.create!(data) - end - - let!(:missing_issues_url) do - data[:encrypted_issues_url] = '' - data[:encrypted_issues_url_iv] = nil - issue_tracker_data.create!(data) - end - - let!(:invalid_new_isue_url) do - data[:encrypted_new_issue_url_iv] = nil - issue_tracker_data.create!(data) - end - - let!(:missing_new_issue_url) do - data[:encrypted_new_issue_url] = '' - issue_tracker_data.create!(data) - end - - let!(:invalid_project_url) do - data[:encrypted_project_url_iv] = nil - issue_tracker_data.create!(data) - end - - let!(:missing_project_url) do - data[:encrypted_project_url] = nil - data[:encrypted_project_url_iv] = nil - issue_tracker_data.create!(data) - end - - it 'removes the invalid data' do - valid_data_records = [valid_data, empty_data, missing_issues_url, missing_new_issue_url, missing_project_url] - - expect { migrate! }.to change { issue_tracker_data.count }.from(8).to(5) - - expect(issue_tracker_data.all).to match_array(valid_data_records) - end -end diff --git a/spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb b/spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb deleted file mode 100644 index cf8bc608483..00000000000 --- a/spec/migrations/20200130145430_reschedule_migrate_issue_trackers_data_spec.rb +++ /dev/null @@ -1,115 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('reschedule_migrate_issue_trackers_data') - -RSpec.describe RescheduleMigrateIssueTrackersData do - let(:services) { table(:services) } - let(:migration_class) { Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData } - let(:migration_name) { migration_class.to_s.demodulize } - - let(:properties) do - { - 'url' => 'http://example.com' - } - end - - let!(:jira_integration) do - services.create!(id: 10, type: 'JiraService', properties: properties, category: 'issue_tracker') - end - - let!(:jira_integration_nil) do - services.create!(id: 11, type: 'JiraService', properties: nil, category: 'issue_tracker') - end - - let!(:bugzilla_integration) do - services.create!(id: 12, type: 'BugzillaService', properties: properties, category: 'issue_tracker') - end - - let!(:youtrack_integration) do - services.create!(id: 13, type: 'YoutrackService', properties: properties, category: 'issue_tracker') - end - - let!(:youtrack_integration_empty) do - services.create!(id: 14, type: 'YoutrackService', properties: '', category: 'issue_tracker') - end - - let!(:gitlab_service) do - services.create!(id: 15, type: 'GitlabIssueTrackerService', properties: properties, category: 'issue_tracker') - end - - let!(:gitlab_service_empty) do - services.create!(id: 16, type: 'GitlabIssueTrackerService', properties: {}, category: 'issue_tracker') - end - - let!(:other_service) do - services.create!(id: 17, type: 'OtherService', properties: properties, category: 'other_category') - end - - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - end - - describe "#up" do - it 'schedules background migrations at correct time' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(migration_name).to be_scheduled_delayed_migration(3.minutes, jira_integration.id, bugzilla_integration.id) - expect(migration_name).to be_scheduled_delayed_migration(6.minutes, youtrack_integration.id, gitlab_service.id) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end - - describe "#down" do - let(:issue_tracker_data) { table(:issue_tracker_data) } - let(:jira_tracker_data) { table(:jira_tracker_data) } - - let!(:valid_issue_tracker_data) do - issue_tracker_data.create!( - service_id: bugzilla_integration.id, - encrypted_issues_url: 'http://url.com', - encrypted_issues_url_iv: 'somevalue' - ) - end - - let!(:invalid_issue_tracker_data) do - issue_tracker_data.create!( - service_id: bugzilla_integration.id, - encrypted_issues_url: 'http:url.com', - encrypted_issues_url_iv: nil - ) - end - - let!(:valid_jira_tracker_data) do - jira_tracker_data.create!( - service_id: bugzilla_integration.id, - encrypted_url: 'http://url.com', - encrypted_url_iv: 'somevalue' - ) - end - - let!(:invalid_jira_tracker_data) do - jira_tracker_data.create!( - service_id: bugzilla_integration.id, - encrypted_url: 'http://url.com', - encrypted_url_iv: nil - ) - end - - it 'removes the invalid jira tracker data' do - expect { described_class.new.down }.to change { jira_tracker_data.count }.from(2).to(1) - - expect(jira_tracker_data.all).to eq([valid_jira_tracker_data]) - end - - it 'removes the invalid issue tracker data' do - expect { described_class.new.down }.to change { issue_tracker_data.count }.from(2).to(1) - - expect(issue_tracker_data.all).to eq([valid_issue_tracker_data]) - end - end -end diff --git a/spec/migrations/20200313203550_remove_orphaned_chat_names_spec.rb b/spec/migrations/20200313203550_remove_orphaned_chat_names_spec.rb deleted file mode 100644 index 6b1126ca53e..00000000000 --- a/spec/migrations/20200313203550_remove_orphaned_chat_names_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('remove_orphaned_chat_names') - -RSpec.describe RemoveOrphanedChatNames, schema: 20200313202430 do - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - let(:services) { table(:services) } - let(:chat_names) { table(:chat_names) } - - let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let(:project) { projects.create!(namespace_id: namespace.id) } - let(:service) { services.create!(project_id: project.id, type: 'chat') } - let(:chat_name) { chat_names.create!(service_id: service.id, team_id: 'TEAM', user_id: 12345, chat_id: 12345) } - let(:orphaned_chat_name) { chat_names.create!(team_id: 'TEAM', service_id: 0, user_id: 12345, chat_id: 12345) } - - it 'removes the orphaned chat_name' do - expect(chat_name).to be_present - expect(orphaned_chat_name).to be_present - - migrate! - - expect(chat_names.where(id: orphaned_chat_name.id)).to be_empty - expect(chat_name.reload).to be_present - end -end diff --git a/spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb b/spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb deleted file mode 100644 index c6a512a1ec9..00000000000 --- a/spec/migrations/20200406102120_backfill_deployment_clusters_from_deployments_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('backfill_deployment_clusters_from_deployments') - -RSpec.describe BackfillDeploymentClustersFromDeployments, :migration, :sidekiq, schema: 20200227140242 do - describe '#up' do - it 'schedules BackfillDeploymentClustersFromDeployments background jobs' do - stub_const("#{described_class}::BATCH_SIZE", 2) - - namespace = table(:namespaces).create!(name: 'the-namespace', path: 'the-path') - project = table(:projects).create!(name: 'the-project', namespace_id: namespace.id) - environment = table(:environments).create!(name: 'the-environment', project_id: project.id, slug: 'slug') - cluster = table(:clusters).create!(name: 'the-cluster') - - deployment_data = { cluster_id: cluster.id, project_id: project.id, environment_id: environment.id, ref: 'abc', tag: false, sha: 'sha', status: 1 } - - # batch 1 - batch_1_begin = create_deployment(**deployment_data) - batch_1_end = create_deployment(**deployment_data) - - # value that should not be included due to default scope - create_deployment(**deployment_data, cluster_id: nil) - - # batch 2 - batch_2_begin = create_deployment(**deployment_data) - batch_2_end = create_deployment(**deployment_data) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - # batch 1 - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, batch_1_begin.id, batch_1_end.id) - - # batch 2 - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, batch_2_begin.id, batch_2_end.id) - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - - def create_deployment(**data) - @iid ||= 0 - @iid += 1 - table(:deployments).create!(iid: @iid, **data) - end - end -end diff --git a/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb b/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb deleted file mode 100644 index e712e555b70..00000000000 --- a/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('change_variable_interpolation_format_in_common_metrics') - -RSpec.describe ChangeVariableInterpolationFormatInCommonMetrics, :migration do - let(:prometheus_metrics) { table(:prometheus_metrics) } - - let!(:common_metric) do - prometheus_metrics.create!( - identifier: 'system_metrics_kubernetes_container_memory_total', - query: 'avg(sum(container_memory_usage_bytes{container_name!="POD",' \ - 'pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"})' \ - ' by (job)) without (job) /1024/1024/1024', - project_id: nil, - title: 'Memory Usage (Total)', - y_label: 'Total Memory Used (GB)', - unit: 'GB', - legend: 'Total (GB)', - group: -5, - common: true - ) - end - - it 'updates query to use {{}}' do - expected_query = <<~EOS.chomp - avg(sum(container_memory_usage_bytes{container!="POD",\ - pod=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"}) \ - by (job)) without (job) /1024/1024/1024 OR \ - avg(sum(container_memory_usage_bytes{container_name!="POD",\ - pod_name=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"}) \ - by (job)) without (job) /1024/1024/1024 - EOS - - migrate! - - expect(common_metric.reload.query).to eq(expected_query) - end -end diff --git a/spec/migrations/20200526115436_dedup_mr_metrics_spec.rb b/spec/migrations/20200526115436_dedup_mr_metrics_spec.rb deleted file mode 100644 index f16026884f5..00000000000 --- a/spec/migrations/20200526115436_dedup_mr_metrics_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('dedup_mr_metrics') - -RSpec.describe DedupMrMetrics, :migration, schema: 20200526013844 do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:merge_requests) { table(:merge_requests) } - let(:metrics) { table(:merge_request_metrics) } - let(:merge_request_params) { { source_branch: 'x', target_branch: 'y', target_project_id: project.id } } - - let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(namespace_id: namespace.id) } - let!(:merge_request_1) { merge_requests.create!(merge_request_params) } - let!(:merge_request_2) { merge_requests.create!(merge_request_params) } - let!(:merge_request_3) { merge_requests.create!(merge_request_params) } - - let!(:duplicated_metrics_1) { metrics.create!(merge_request_id: merge_request_1.id, latest_build_started_at: 1.day.ago, first_deployed_to_production_at: 5.days.ago, updated_at: 2.months.ago) } - let!(:duplicated_metrics_2) { metrics.create!(merge_request_id: merge_request_1.id, latest_build_started_at: Time.now, merged_at: Time.now, updated_at: 1.month.ago) } - - let!(:duplicated_metrics_3) { metrics.create!(merge_request_id: merge_request_3.id, diff_size: 30, commits_count: 20, updated_at: 2.months.ago) } - let!(:duplicated_metrics_4) { metrics.create!(merge_request_id: merge_request_3.id, added_lines: 5, commits_count: nil, updated_at: 1.month.ago) } - - let!(:non_duplicated_metrics) { metrics.create!(merge_request_id: merge_request_2.id, latest_build_started_at: 2.days.ago) } - - it 'deduplicates merge_request_metrics table' do - expect { migrate! }.to change { metrics.count }.from(5).to(3) - end - - it 'merges `duplicated_metrics_1` with `duplicated_metrics_2`' do - migrate! - - expect(metrics.where(id: duplicated_metrics_1.id)).not_to exist - - merged_metrics = metrics.find_by(id: duplicated_metrics_2.id) - - expect(merged_metrics).to be_present - expect(merged_metrics.latest_build_started_at).to be_like_time(duplicated_metrics_2.latest_build_started_at) - expect(merged_metrics.merged_at).to be_like_time(duplicated_metrics_2.merged_at) - expect(merged_metrics.first_deployed_to_production_at).to be_like_time(duplicated_metrics_1.first_deployed_to_production_at) - end - - it 'merges `duplicated_metrics_3` with `duplicated_metrics_4`' do - migrate! - - expect(metrics.where(id: duplicated_metrics_3.id)).not_to exist - - merged_metrics = metrics.find_by(id: duplicated_metrics_4.id) - - expect(merged_metrics).to be_present - expect(merged_metrics.diff_size).to eq(duplicated_metrics_3.diff_size) - expect(merged_metrics.commits_count).to eq(duplicated_metrics_3.commits_count) - expect(merged_metrics.added_lines).to eq(duplicated_metrics_4.added_lines) - end - - it 'does not change non duplicated records' do - expect { migrate! }.not_to change { non_duplicated_metrics.reload.attributes } - end - - it 'does nothing when there are no metrics' do - metrics.delete_all - - migrate! - - expect(metrics.count).to eq(0) - end -end diff --git a/spec/migrations/20200526231421_update_index_approval_rule_name_for_code_owners_rule_type_spec.rb b/spec/migrations/20200526231421_update_index_approval_rule_name_for_code_owners_rule_type_spec.rb deleted file mode 100644 index 9b72559234e..00000000000 --- a/spec/migrations/20200526231421_update_index_approval_rule_name_for_code_owners_rule_type_spec.rb +++ /dev/null @@ -1,175 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('update_index_approval_rule_name_for_code_owners_rule_type') - -RSpec.describe UpdateIndexApprovalRuleNameForCodeOwnersRuleType do - let(:migration) { described_class.new } - - let(:approval_rules) { table(:approval_merge_request_rules) } - let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab') } - - let(:project) do - table(:projects).create!( - namespace_id: namespace.id, - name: 'gitlab', - path: 'gitlab' - ) - end - - let(:merge_request) do - table(:merge_requests).create!( - target_project_id: project.id, - source_project_id: project.id, - target_branch: 'feature', - source_branch: 'master' - ) - end - - let(:index_names) do - ActiveRecord::Base.connection - .indexes(:approval_merge_request_rules) - .collect(&:name) - end - - def create_sectional_approval_rules - approval_rules.create!( - merge_request_id: merge_request.id, - name: "*.rb", - code_owner: true, - rule_type: 2, - section: "First Section" - ) - - approval_rules.create!( - merge_request_id: merge_request.id, - name: "*.rb", - code_owner: true, - rule_type: 2, - section: "Second Section" - ) - end - - def create_two_matching_nil_section_approval_rules - 2.times do - approval_rules.create!( - merge_request_id: merge_request.id, - name: "nil_section", - code_owner: true, - rule_type: 2 - ) - end - end - - before do - approval_rules.delete_all - end - - describe "#up" do - it "creates the new index and removes the 'legacy' indices" do - # Confirm that existing legacy indices prevent duplicate entries - # - expect { create_sectional_approval_rules } - .to raise_exception(ActiveRecord::RecordNotUnique) - expect { create_two_matching_nil_section_approval_rules } - .to raise_exception(ActiveRecord::RecordNotUnique) - - approval_rules.delete_all - - disable_migrations_output { migrate! } - - # After running the migration, expect `section == nil` rules to still be - # blocked by the legacy indices, but sectional rules are allowed. - # - expect { create_sectional_approval_rules } - .to change { approval_rules.count }.by(2) - expect { create_two_matching_nil_section_approval_rules } - .to raise_exception(ActiveRecord::RecordNotUnique) - - # Attempt to rerun the creation of sectional rules, and see that sectional - # rules are unique by section - # - expect { create_sectional_approval_rules } - .to raise_exception(ActiveRecord::RecordNotUnique) - - expect(index_names).to include( - described_class::SECTIONAL_INDEX_NAME, - described_class::LEGACY_INDEX_NAME_RULE_TYPE, - described_class::LEGACY_INDEX_NAME_CODE_OWNERS - ) - end - end - - describe "#down" do - context "run as FOSS" do - before do - expect(Gitlab).to receive(:ee?).twice.and_return(false) - end - - it "recreates legacy indices, but does not invoke EE-specific code" do - disable_migrations_output { migrate! } - - expect(index_names).to include( - described_class::SECTIONAL_INDEX_NAME, - described_class::LEGACY_INDEX_NAME_RULE_TYPE, - described_class::LEGACY_INDEX_NAME_CODE_OWNERS - ) - - # Since ApprovalMergeRequestRules are EE-specific, we expect none to be - # deleted during the migration. - # - expect { disable_migrations_output { migration.down } } - .not_to change { approval_rules.count } - - index_names = ActiveRecord::Base.connection - .indexes(:approval_merge_request_rules) - .collect(&:name) - - expect(index_names).not_to include(described_class::SECTIONAL_INDEX_NAME) - expect(index_names).to include( - described_class::LEGACY_INDEX_NAME_RULE_TYPE, - described_class::LEGACY_INDEX_NAME_CODE_OWNERS - ) - end - end - - context "EE" do - it "recreates 'legacy' indices and removes duplicate code owner approval rules" do - skip("This test is skipped under FOSS") unless Gitlab.ee? - - disable_migrations_output { migrate! } - - expect { create_sectional_approval_rules } - .to change { approval_rules.count }.by(2) - expect { create_two_matching_nil_section_approval_rules } - .to raise_exception(ActiveRecord::RecordNotUnique) - - expect(MergeRequests::SyncCodeOwnerApprovalRules) - .to receive(:new).with(MergeRequest.find(merge_request.id)).once.and_call_original - - # Run the down migration. This will remove the 3 approval rules we create - # above, and call MergeRequests::SyncCodeOwnerApprovalRules to recreate - # new ones. However, as there is no CODEOWNERS file in this test - # context, no approval rules will be created, so we can expect - # approval_rules.count to be changed by -3. - # - expect { disable_migrations_output { migration.down } } - .to change { approval_rules.count }.by(-3) - - # Test that the index does not allow us to create the same rules as the - # previous sectional index. - # - expect { create_sectional_approval_rules } - .to raise_exception(ActiveRecord::RecordNotUnique) - expect { create_two_matching_nil_section_approval_rules } - .to raise_exception(ActiveRecord::RecordNotUnique) - - expect(index_names).not_to include(described_class::SECTIONAL_INDEX_NAME) - expect(index_names).to include( - described_class::LEGACY_INDEX_NAME_RULE_TYPE, - described_class::LEGACY_INDEX_NAME_CODE_OWNERS - ) - end - end - end -end diff --git a/spec/migrations/20200703125016_backfill_namespace_settings_spec.rb b/spec/migrations/20200703125016_backfill_namespace_settings_spec.rb deleted file mode 100644 index c9f7a66a0b9..00000000000 --- a/spec/migrations/20200703125016_backfill_namespace_settings_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('backfill_namespace_settings') - -RSpec.describe BackfillNamespaceSettings, :sidekiq, schema: 20200703124823 do - let(:namespaces) { table(:namespaces) } - - describe '#up' do - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - - namespaces.create!(id: 1, name: 'test1', path: 'test1') - namespaces.create!(id: 2, name: 'test2', path: 'test2') - namespaces.create!(id: 3, name: 'test3', path: 'test3') - end - - it 'schedules BackfillNamespaceSettings background jobs' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 1, 2) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 3, 3) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end -end diff --git a/spec/migrations/20200706035141_adjust_unique_index_alert_management_alerts_spec.rb b/spec/migrations/20200706035141_adjust_unique_index_alert_management_alerts_spec.rb deleted file mode 100644 index 121b1729dd2..00000000000 --- a/spec/migrations/20200706035141_adjust_unique_index_alert_management_alerts_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('adjust_unique_index_alert_management_alerts') - -RSpec.describe AdjustUniqueIndexAlertManagementAlerts, :migration do - let(:migration) { described_class.new } - let(:alerts) { AlertManagement::Alert } - let(:project) { create_project } - let(:other_project) { create_project } - let(:resolved_state) { 2 } - let(:triggered_state) { 1 } - let!(:existing_alert) { create_alert(project, resolved_state, '1234', 1) } - let!(:p2_alert) { create_alert(other_project, resolved_state, '1234', 1) } - let!(:p2_alert_diff_fingerprint) { create_alert(other_project, resolved_state, '4567', 2) } - - it 'can reverse the migration' do - expect(existing_alert.fingerprint).not_to eq(nil) - expect(p2_alert.fingerprint).not_to eq(nil) - expect(p2_alert_diff_fingerprint.fingerprint).not_to eq(nil) - - migrate! - - # Adding a second alert with the same fingerprint now that we can - second_alert = create_alert(project, triggered_state, '1234', 2) - expect(alerts.count).to eq(4) - - schema_migrate_down! - - # We keep the alerts, but the oldest ones fingerprint is removed - expect(alerts.count).to eq(4) - expect(second_alert.reload.fingerprint).not_to eq(nil) - expect(p2_alert.fingerprint).not_to eq(nil) - expect(p2_alert_diff_fingerprint.fingerprint).not_to eq(nil) - expect(existing_alert.reload.fingerprint).to eq(nil) - end - - def namespace - @namespace ||= table(:namespaces).create!(name: 'foo', path: 'foo') - end - - def create_project - table(:projects).create!(namespace_id: namespace.id) - end - - def create_alert(project, status, fingerprint, iid) - params = { - title: 'test', - started_at: Time.current, - iid: iid, - project_id: project.id, - status: status, - fingerprint: fingerprint - } - table(:alert_management_alerts).create!(params) - end -end diff --git a/spec/migrations/20200728080250_replace_unique_index_on_cycle_analytics_stages_spec.rb b/spec/migrations/20200728080250_replace_unique_index_on_cycle_analytics_stages_spec.rb deleted file mode 100644 index a632065946d..00000000000 --- a/spec/migrations/20200728080250_replace_unique_index_on_cycle_analytics_stages_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('replace_unique_index_on_cycle_analytics_stages') - -RSpec.describe ReplaceUniqueIndexOnCycleAnalyticsStages, :migration, schema: 20200727142337 do - let(:namespaces) { table(:namespaces) } - let(:group_value_streams) { table(:analytics_cycle_analytics_group_value_streams) } - let(:group_stages) { table(:analytics_cycle_analytics_group_stages) } - - let(:group) { namespaces.create!(type: 'Group', name: 'test', path: 'test') } - - let(:value_stream_1) { group_value_streams.create!(group_id: group.id, name: 'vs1') } - let(:value_stream_2) { group_value_streams.create!(group_id: group.id, name: 'vs2') } - - let(:duplicated_stage_1) { group_stages.create!(group_id: group.id, group_value_stream_id: value_stream_1.id, name: 'stage', start_event_identifier: 1, end_event_identifier: 1) } - let(:duplicated_stage_2) { group_stages.create!(group_id: group.id, group_value_stream_id: value_stream_2.id, name: 'stage', start_event_identifier: 1, end_event_identifier: 1) } - - let(:stage_record) { group_stages.create!(group_id: group.id, group_value_stream_id: value_stream_2.id, name: 'other stage', start_event_identifier: 1, end_event_identifier: 1) } - - describe '#down' do - subject { described_class.new.down } - - before do - described_class.new.up - - duplicated_stage_1 - duplicated_stage_2 - stage_record - end - - it 'removes duplicated stage records' do - subject - - stage = group_stages.find_by_id(duplicated_stage_2.id) - expect(stage).to be_nil - end - - it 'does not change the first duplicated stage record' do - expect { subject }.not_to change { duplicated_stage_1.reload.attributes } - end - - it 'does not change not duplicated stage record' do - expect { subject }.not_to change { stage_record.reload.attributes } - end - end -end diff --git a/spec/migrations/20200728182311_add_o_auth_paths_to_protected_paths_spec.rb b/spec/migrations/20200728182311_add_o_auth_paths_to_protected_paths_spec.rb deleted file mode 100644 index 5c65d45c6e0..00000000000 --- a/spec/migrations/20200728182311_add_o_auth_paths_to_protected_paths_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('add_o_auth_paths_to_protected_paths') - -RSpec.describe AddOAuthPathsToProtectedPaths do - subject(:migration) { described_class.new } - - let(:application_settings) { table(:application_settings) } - let(:new_paths) do - [ - '/oauth/authorize', - '/oauth/token' - ] - end - - it 'appends new OAuth paths' do - application_settings.create! - - protected_paths_before = application_settings.first.protected_paths - protected_paths_after = protected_paths_before + new_paths - - expect { migrate! }.to change { application_settings.first.protected_paths }.from(protected_paths_before).to(protected_paths_after) - end - - it 'new default includes new paths' do - settings_before = application_settings.create! - - expect(settings_before.protected_paths).not_to include(*new_paths) - - migrate! - - application_settings.reset_column_information - settings_after = application_settings.create! - - expect(settings_after.protected_paths).to include(*new_paths) - end - - it 'does not change the value when the new paths are already included' do - application_settings.create!(protected_paths: %w(/users/sign_in /users/password) + new_paths) - - expect { migrate! }.not_to change { application_settings.first.protected_paths } - end - - it 'adds one value when the other is already present' do - application_settings.create!(protected_paths: %W(/users/sign_in /users/password #{new_paths.first})) - - migrate! - - expect(application_settings.first.protected_paths).to include(new_paths.second) - end -end diff --git a/spec/migrations/20200811130433_create_missing_vulnerabilities_issue_links_spec.rb b/spec/migrations/20200811130433_create_missing_vulnerabilities_issue_links_spec.rb deleted file mode 100644 index d166ff3617b..00000000000 --- a/spec/migrations/20200811130433_create_missing_vulnerabilities_issue_links_spec.rb +++ /dev/null @@ -1,160 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' -require_migration!('create_missing_vulnerabilities_issue_links') - -RSpec.describe CreateMissingVulnerabilitiesIssueLinks, :migration do - let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } - let(:users) { table(:users) } - let(:user) { create_user! } - let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) } - let(:scanners) { table(:vulnerability_scanners) } - let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } - let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } - let(:issues) { table(:issues) } - let(:issue1) { issues.create!(id: 123, project_id: project.id) } - let(:issue2) { issues.create!(id: 124, project_id: project.id) } - let(:issue3) { issues.create!(id: 125, project_id: project.id) } - let(:vulnerabilities) { table(:vulnerabilities) } - let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } - let(:vulnerability_feedback) { table(:vulnerability_feedback) } - let(:vulnerability_issue_links) { table(:vulnerability_issue_links) } - let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } - let(:vulnerability_identifier) { vulnerability_identifiers.create!(project_id: project.id, external_type: 'test 1', external_id: 'test 1', fingerprint: 'test 1', name: 'test 1') } - let(:different_vulnerability_identifier) { vulnerability_identifiers.create!(project_id: project.id, external_type: 'test 2', external_id: 'test 2', fingerprint: 'test 2', name: 'test 2') } - - let!(:vulnerability) do - create_vulnerability!( - project_id: project.id, - author_id: user.id - ) - end - - before do - create_finding!( - vulnerability_id: vulnerability.id, - project_id: project.id, - scanner_id: scanner.id, - primary_identifier_id: vulnerability_identifier.id - ) - create_feedback!( - issue_id: issue1.id, - project_id: project.id, - author_id: user.id - ) - - # Create a finding with no vulnerability_id - # https://gitlab.com/gitlab-com/gl-infra/production/-/issues/2539 - create_finding!( - vulnerability_id: nil, - project_id: project.id, - scanner_id: different_scanner.id, - primary_identifier_id: different_vulnerability_identifier.id, - location_fingerprint: 'somewhereinspace', - uuid: 'test2' - ) - create_feedback!( - category: 2, - issue_id: issue2.id, - project_id: project.id, - author_id: user.id - ) - end - - context 'with no Vulnerabilities::IssueLinks present' do - it 'creates missing Vulnerabilities::IssueLinks' do - expect(vulnerability_issue_links.count).to eq(0) - - migrate! - - expect(vulnerability_issue_links.count).to eq(1) - end - end - - context 'when an Vulnerabilities::IssueLink already exists' do - before do - vulnerability_issue_links.create!(vulnerability_id: vulnerability.id, issue_id: issue1.id) - end - - it 'creates no duplicates' do - expect(vulnerability_issue_links.count).to eq(1) - - migrate! - - expect(vulnerability_issue_links.count).to eq(1) - end - end - - context 'when an Vulnerabilities::IssueLink of type created already exists' do - before do - vulnerability_issue_links.create!(vulnerability_id: vulnerability.id, issue_id: issue3.id, link_type: 2) - end - - it 'creates no duplicates' do - expect(vulnerability_issue_links.count).to eq(1) - - migrate! - - expect(vulnerability_issue_links.count).to eq(1) - end - end - - private - - def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0) - vulnerabilities.create!( - project_id: project_id, - author_id: author_id, - title: title, - severity: severity, - confidence: confidence, - report_type: report_type - ) - end - - # rubocop:disable Metrics/ParameterLists - def create_finding!( - vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, - name: "test", severity: 7, confidence: 7, report_type: 0, - project_fingerprint: '123qweasdzxc', location_fingerprint: 'test', - metadata_version: 'test', raw_metadata: 'test', uuid: 'test') - vulnerabilities_findings.create!( - vulnerability_id: vulnerability_id, - project_id: project_id, - name: name, - severity: severity, - confidence: confidence, - report_type: report_type, - project_fingerprint: project_fingerprint, - scanner_id: scanner.id, - primary_identifier_id: vulnerability_identifier.id, - location_fingerprint: location_fingerprint, - metadata_version: metadata_version, - raw_metadata: raw_metadata, - uuid: uuid - ) - end - # rubocop:enable Metrics/ParameterLists - - # project_fingerprint on Vulnerabilities::Finding is a bytea and we need to match this - def create_feedback!(issue_id:, project_id:, author_id:, feedback_type: 1, category: 0, project_fingerprint: '3132337177656173647a7863') - vulnerability_feedback.create!( - feedback_type: feedback_type, - issue_id: issue_id, - category: category, - project_fingerprint: project_fingerprint, - project_id: project_id, - author_id: author_id - ) - end - - def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.now, confirmed_at: Time.now) - users.create!( - name: name, - email: email, - username: name, - projects_limit: 0, - user_type: user_type, - confirmed_at: confirmed_at - ) - end -end diff --git a/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb b/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb deleted file mode 100644 index 69f7525d265..00000000000 --- a/spec/migrations/20200915044225_schedule_migration_to_hashed_storage_spec.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('schedule_migration_to_hashed_storage') - -RSpec.describe ScheduleMigrationToHashedStorage, :sidekiq do - describe '#up' do - it 'schedules background migration job' do - Sidekiq::Testing.fake! do - expect { migrate! }.to change { BackgroundMigrationWorker.jobs.size }.by(1) - end - end - end -end diff --git a/spec/migrations/20200929052138_create_initial_versions_for_pre_versioning_terraform_states_spec.rb b/spec/migrations/20200929052138_create_initial_versions_for_pre_versioning_terraform_states_spec.rb deleted file mode 100644 index 34bd8f1c869..00000000000 --- a/spec/migrations/20200929052138_create_initial_versions_for_pre_versioning_terraform_states_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('create_initial_versions_for_pre_versioning_terraform_states') - -RSpec.describe CreateInitialVersionsForPreVersioningTerraformStates do - let(:namespace) { table(:namespaces).create!(name: 'terraform', path: 'terraform') } - let(:project) { table(:projects).create!(id: 1, namespace_id: namespace.id) } - let(:terraform_state_versions) { table(:terraform_state_versions) } - - def create_state!(project, versioning_enabled:) - table(:terraform_states).create!( - project_id: project.id, - uuid: 'uuid', - file_store: 2, - file: 'state.tfstate', - versioning_enabled: versioning_enabled - ) - end - - describe '#up' do - context 'for a state that is already versioned' do - let!(:terraform_state) { create_state!(project, versioning_enabled: true) } - - it 'does not insert a version record' do - expect { migrate! }.not_to change { terraform_state_versions.count } - end - end - - context 'for a state that is not yet versioned' do - let!(:terraform_state) { create_state!(project, versioning_enabled: false) } - - it 'creates a version using the current state data' do - expect { migrate! }.to change { terraform_state_versions.count }.by(1) - - migrated_version = terraform_state_versions.last - expect(migrated_version.terraform_state_id).to eq(terraform_state.id) - expect(migrated_version.version).to be_zero - expect(migrated_version.file_store).to eq(terraform_state.file_store) - expect(migrated_version.file).to eq(terraform_state.file) - expect(migrated_version.created_at).to be_present - expect(migrated_version.updated_at).to be_present - end - end - end -end diff --git a/spec/migrations/20201014205300_drop_backfill_jira_tracker_deployment_type_jobs_spec.rb b/spec/migrations/20201014205300_drop_backfill_jira_tracker_deployment_type_jobs_spec.rb deleted file mode 100644 index ef9bc5788c1..00000000000 --- a/spec/migrations/20201014205300_drop_backfill_jira_tracker_deployment_type_jobs_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('drop_backfill_jira_tracker_deployment_type_jobs') - -RSpec.describe DropBackfillJiraTrackerDeploymentTypeJobs, :sidekiq, :redis, schema: 2020_10_14_205300 do - subject(:migration) { described_class.new } - - describe '#up' do - let(:retry_set) { Sidekiq::RetrySet.new } - let(:scheduled_set) { Sidekiq::ScheduledSet.new } - - context 'there are only affected jobs on the queue' do - let(:payload) { { 'class' => ::BackgroundMigrationWorker, 'args' => [described_class::DROPPED_JOB_CLASS, 1] } } - let(:queue_payload) { payload.merge('queue' => described_class::QUEUE) } - - it 'removes enqueued BackfillJiraTrackerDeploymentType background jobs' do - Sidekiq::Testing.disable! do # https://github.com/mperham/sidekiq/wiki/testing#api Sidekiq's API does not have a testing mode - retry_set.schedule(1.hour.from_now, payload) - scheduled_set.schedule(1.hour.from_now, payload) - Sidekiq::Client.push(queue_payload) - - expect { migration.up }.to change { Sidekiq::Queue.new(described_class::QUEUE).size }.from(1).to(0) - expect(retry_set.size).to eq(0) - expect(scheduled_set.size).to eq(0) - end - end - end - - context 'there are not any affected jobs on the queue' do - let(:payload) { { 'class' => ::BackgroundMigrationWorker, 'args' => ['SomeOtherClass', 1] } } - let(:queue_payload) { payload.merge('queue' => described_class::QUEUE) } - - it 'skips other enqueued jobs' do - Sidekiq::Testing.disable! do - retry_set.schedule(1.hour.from_now, payload) - scheduled_set.schedule(1.hour.from_now, payload) - Sidekiq::Client.push(queue_payload) - - expect { migration.up }.not_to change { Sidekiq::Queue.new(described_class::QUEUE).size } - expect(retry_set.size).to eq(1) - expect(scheduled_set.size).to eq(1) - end - end - end - - context 'other queues' do - it 'does not modify them' do - Sidekiq::Testing.disable! do - Sidekiq::Client.push('queue' => 'other', 'class' => ::BackgroundMigrationWorker, 'args' => ['SomeOtherClass', 1]) - Sidekiq::Client.push('queue' => 'other', 'class' => ::BackgroundMigrationWorker, 'args' => [described_class::DROPPED_JOB_CLASS, 1]) - - expect { migration.up }.not_to change { Sidekiq::Queue.new('other').size } - end - end - end - end -end diff --git a/spec/migrations/20201027002551_migrate_services_to_http_integrations_spec.rb b/spec/migrations/20201027002551_migrate_services_to_http_integrations_spec.rb deleted file mode 100644 index f9f6cd9589c..00000000000 --- a/spec/migrations/20201027002551_migrate_services_to_http_integrations_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('migrate_services_to_http_integrations') - -RSpec.describe MigrateServicesToHttpIntegrations do - let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } - let!(:project) { table(:projects).create!(id: 1, namespace_id: namespace.id) } - let!(:alert_service) { table(:services).create!(type: 'AlertsService', project_id: project.id, active: true) } - let!(:alert_service_data) { table(:alerts_service_data).create!(service_id: alert_service.id, encrypted_token: 'test', encrypted_token_iv: 'test')} - let(:http_integrations) { table(:alert_management_http_integrations) } - - describe '#up' do - it 'creates the http integrations from the alert services', :aggregate_failures do - expect { migrate! }.to change { http_integrations.count }.by(1) - - http_integration = http_integrations.last - expect(http_integration.project_id).to eq(alert_service.project_id) - expect(http_integration.encrypted_token).to eq(alert_service_data.encrypted_token) - expect(http_integration.encrypted_token_iv).to eq(alert_service_data.encrypted_token_iv) - expect(http_integration.active).to eq(alert_service.active) - expect(http_integration.name).to eq(described_class::SERVICE_NAMES_IDENTIFIER[:name]) - expect(http_integration.endpoint_identifier).to eq(described_class::SERVICE_NAMES_IDENTIFIER[:identifier]) - end - end -end diff --git a/spec/migrations/20201028182809_backfill_jira_tracker_deployment_type2_spec.rb b/spec/migrations/20201028182809_backfill_jira_tracker_deployment_type2_spec.rb deleted file mode 100644 index 0746ad7e44f..00000000000 --- a/spec/migrations/20201028182809_backfill_jira_tracker_deployment_type2_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('backfill_jira_tracker_deployment_type2') - -RSpec.describe BackfillJiraTrackerDeploymentType2, :sidekiq, schema: 20201028182809 do - let(:services) { table(:services) } - let(:jira_tracker_data) { table(:jira_tracker_data) } - let(:migration) { described_class.new } - let(:batch_interval) { described_class::DELAY_INTERVAL } - - describe '#up' do - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - - active_service = services.create!(type: 'JiraService', active: true) - inactive_service = services.create!(type: 'JiraService', active: false) - - jira_tracker_data.create!(id: 1, service_id: active_service.id, deployment_type: 0) - jira_tracker_data.create!(id: 2, service_id: active_service.id, deployment_type: 1) - jira_tracker_data.create!(id: 3, service_id: inactive_service.id, deployment_type: 2) - jira_tracker_data.create!(id: 4, service_id: inactive_service.id, deployment_type: 0) - jira_tracker_data.create!(id: 5, service_id: active_service.id, deployment_type: 0) - end - - it 'schedules BackfillJiraTrackerDeploymentType2 background jobs' do - Sidekiq::Testing.fake! do - freeze_time do - migration.up - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(batch_interval, 1, 4) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(batch_interval * 2, 5, 5) - end - end - end - end -end diff --git a/spec/migrations/20201110161542_cleanup_transfered_projects_shared_runners_spec.rb b/spec/migrations/20201110161542_cleanup_transfered_projects_shared_runners_spec.rb deleted file mode 100644 index 7a79406ac80..00000000000 --- a/spec/migrations/20201110161542_cleanup_transfered_projects_shared_runners_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('cleanup_transfered_projects_shared_runners') - -RSpec.describe CleanupTransferedProjectsSharedRunners, :sidekiq, schema: 20201110161542 do - let(:namespaces) { table(:namespaces) } - let(:migration) { described_class.new } - let(:batch_interval) { described_class::INTERVAL } - - let!(:namespace_1) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:namespace_2) { namespaces.create!(name: 'bar', path: 'bar') } - let!(:namespace_3) { namespaces.create!(name: 'baz', path: 'baz') } - - describe '#up' do - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - end - - it 'schedules ResetSharedRunnersForTransferredProjects background jobs' do - Sidekiq::Testing.fake! do - freeze_time do - migration.up - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(batch_interval, namespace_1.id, namespace_2.id) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(batch_interval * 2, namespace_3.id, namespace_3.id) - end - end - end - end -end diff --git a/spec/migrations/20201112130710_schedule_remove_duplicate_vulnerabilities_findings_spec.rb b/spec/migrations/20201112130710_schedule_remove_duplicate_vulnerabilities_findings_spec.rb deleted file mode 100644 index 92a716c355b..00000000000 --- a/spec/migrations/20201112130710_schedule_remove_duplicate_vulnerabilities_findings_spec.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' -require_migration!('schedule_remove_duplicate_vulnerabilities_findings') - -RSpec.describe ScheduleRemoveDuplicateVulnerabilitiesFindings, :migration do - let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } - let(:users) { table(:users) } - let(:user) { create_user! } - let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) } - let(:scanners) { table(:vulnerability_scanners) } - let!(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } - let!(:scanner2) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } - let!(:scanner3) { scanners.create!(project_id: project.id, external_id: 'test 3', name: 'test scanner 3') } - let!(:unrelated_scanner) { scanners.create!(project_id: project.id, external_id: 'unreleated_scanner', name: 'unrelated scanner') } - let(:vulnerabilities) { table(:vulnerabilities) } - let(:vulnerability_findings) { table(:vulnerability_occurrences) } - let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } - let(:vulnerability_identifier) do - vulnerability_identifiers.create!( - project_id: project.id, - external_type: 'vulnerability-identifier', - external_id: 'vulnerability-identifier', - fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a', - name: 'vulnerability identifier') - end - - let!(:first_finding) do - create_finding!( - uuid: "test1", - vulnerability_id: nil, - report_type: 0, - location_fingerprint: '2bda3014914481791847d8eca38d1a8d13b6ad76', - primary_identifier_id: vulnerability_identifier.id, - scanner_id: scanner.id, - project_id: project.id - ) - end - - let!(:first_duplicate) do - create_finding!( - uuid: "test2", - vulnerability_id: nil, - report_type: 0, - location_fingerprint: '2bda3014914481791847d8eca38d1a8d13b6ad76', - primary_identifier_id: vulnerability_identifier.id, - scanner_id: scanner2.id, - project_id: project.id - ) - end - - let!(:second_duplicate) do - create_finding!( - uuid: "test3", - vulnerability_id: nil, - report_type: 0, - location_fingerprint: '2bda3014914481791847d8eca38d1a8d13b6ad76', - primary_identifier_id: vulnerability_identifier.id, - scanner_id: scanner3.id, - project_id: project.id - ) - end - - let!(:unrelated_finding) do - create_finding!( - uuid: "unreleated_finding", - vulnerability_id: nil, - report_type: 1, - location_fingerprint: 'random_location_fingerprint', - primary_identifier_id: vulnerability_identifier.id, - scanner_id: unrelated_scanner.id, - project_id: project.id - ) - end - - before do - stub_const("#{described_class}::BATCH_SIZE", 1) - end - - around do |example| - freeze_time { Sidekiq::Testing.fake! { example.run } } - end - - it 'schedules background migration' do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(4) - expect(described_class::MIGRATION).to be_scheduled_migration(first_finding.id, first_finding.id) - expect(described_class::MIGRATION).to be_scheduled_migration(first_duplicate.id, first_duplicate.id) - expect(described_class::MIGRATION).to be_scheduled_migration(second_duplicate.id, second_duplicate.id) - expect(described_class::MIGRATION).to be_scheduled_migration(unrelated_finding.id, unrelated_finding.id) - end - - private - - def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0) - vulnerabilities.create!( - project_id: project_id, - author_id: author_id, - title: title, - severity: severity, - confidence: confidence, - report_type: report_type - ) - end - - # rubocop:disable Metrics/ParameterLists - def create_finding!( - vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, - name: "test", severity: 7, confidence: 7, report_type: 0, - project_fingerprint: '123qweasdzxc', location_fingerprint: 'test', - metadata_version: 'test', raw_metadata: 'test', uuid: 'test') - vulnerability_findings.create!( - vulnerability_id: vulnerability_id, - project_id: project_id, - name: name, - severity: severity, - confidence: confidence, - report_type: report_type, - project_fingerprint: project_fingerprint, - scanner_id: scanner_id, - primary_identifier_id: vulnerability_identifier.id, - location_fingerprint: location_fingerprint, - metadata_version: metadata_version, - raw_metadata: raw_metadata, - uuid: uuid - ) - end - # rubocop:enable Metrics/ParameterLists - - def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.now, confirmed_at: Time.now) - users.create!( - name: name, - email: email, - username: name, - projects_limit: 0, - user_type: user_type, - confirmed_at: confirmed_at - ) - end -end diff --git a/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb b/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb deleted file mode 100644 index dda919d70d9..00000000000 --- a/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('schedule_recalculate_uuid_on_vulnerabilities_occurrences') - -RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences, :migration do - let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } - let(:users) { table(:users) } - let(:user) { create_user! } - let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) } - let(:scanners) { table(:vulnerability_scanners) } - let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } - let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } - let(:vulnerabilities) { table(:vulnerabilities) } - let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } - let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } - let(:vulnerability_identifier) do - vulnerability_identifiers.create!( - project_id: project.id, - external_type: 'uuid-v5', - external_id: 'uuid-v5', - fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a', - name: 'Identifier for UUIDv5') - end - - let(:different_vulnerability_identifier) do - vulnerability_identifiers.create!( - project_id: project.id, - external_type: 'uuid-v4', - external_id: 'uuid-v4', - fingerprint: '772da93d34a1ba010bcb5efa9fb6f8e01bafcc89', - name: 'Identifier for UUIDv4') - end - - let!(:vulnerability_for_uuidv4) do - create_vulnerability!( - project_id: project.id, - author_id: user.id - ) - end - - let!(:vulnerability_for_uuidv5) do - create_vulnerability!( - project_id: project.id, - author_id: user.id - ) - end - - let(:known_uuid_v4) { "b3cc2518-5446-4dea-871c-89d5e999c1ac" } - let!(:finding_with_uuid_v4) do - create_finding!( - vulnerability_id: vulnerability_for_uuidv4.id, - project_id: project.id, - scanner_id: different_scanner.id, - primary_identifier_id: different_vulnerability_identifier.id, - report_type: 0, # "sast" - location_fingerprint: "fa18f432f1d56675f4098d318739c3cd5b14eb3e", - uuid: known_uuid_v4 - ) - end - - let(:known_uuid_v5) { "e7d3d99d-04bb-5771-bb44-d80a9702d0a2" } - let!(:finding_with_uuid_v5) do - create_finding!( - vulnerability_id: vulnerability_for_uuidv5.id, - project_id: project.id, - scanner_id: scanner.id, - primary_identifier_id: vulnerability_identifier.id, - report_type: 0, # "sast" - location_fingerprint: "838574be0210968bf6b9f569df9c2576242cbf0a", - uuid: known_uuid_v5 - ) - end - - before do - stub_const("#{described_class}::BATCH_SIZE", 1) - end - - around do |example| - freeze_time { Sidekiq::Testing.fake! { example.run } } - end - - it 'schedules background migration' do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_migration(finding_with_uuid_v4.id, finding_with_uuid_v4.id) - expect(described_class::MIGRATION).to be_scheduled_migration(finding_with_uuid_v5.id, finding_with_uuid_v5.id) - end - - private - - def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0) - vulnerabilities.create!( - project_id: project_id, - author_id: author_id, - title: title, - severity: severity, - confidence: confidence, - report_type: report_type - ) - end - - # rubocop:disable Metrics/ParameterLists - def create_finding!( - vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, - name: "test", severity: 7, confidence: 7, report_type: 0, - project_fingerprint: '123qweasdzxc', location_fingerprint: 'test', - metadata_version: 'test', raw_metadata: 'test', uuid: 'test') - vulnerabilities_findings.create!( - vulnerability_id: vulnerability_id, - project_id: project_id, - name: name, - severity: severity, - confidence: confidence, - report_type: report_type, - project_fingerprint: project_fingerprint, - scanner_id: scanner.id, - primary_identifier_id: vulnerability_identifier.id, - location_fingerprint: location_fingerprint, - metadata_version: metadata_version, - raw_metadata: raw_metadata, - uuid: uuid - ) - end - # rubocop:enable Metrics/ParameterLists - - def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.now, confirmed_at: Time.now) - users.create!( - name: name, - email: email, - username: name, - projects_limit: 0, - user_type: user_type, - confirmed_at: confirmed_at - ) - end -end diff --git a/spec/migrations/20210112143418_remove_duplicate_services2_spec.rb b/spec/migrations/20210112143418_remove_duplicate_services2_spec.rb index 289416c22cf..b8dc4d7c8ae 100644 --- a/spec/migrations/20210112143418_remove_duplicate_services2_spec.rb +++ b/spec/migrations/20210112143418_remove_duplicate_services2_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('remove_duplicate_services2') +require_migration! RSpec.describe RemoveDuplicateServices2 do let_it_be(:namespaces) { table(:namespaces) } diff --git a/spec/migrations/20210119122354_alter_vsa_issue_first_mentioned_in_commit_value_spec.rb b/spec/migrations/20210119122354_alter_vsa_issue_first_mentioned_in_commit_value_spec.rb index 469dbb4f946..e07b5a48909 100644 --- a/spec/migrations/20210119122354_alter_vsa_issue_first_mentioned_in_commit_value_spec.rb +++ b/spec/migrations/20210119122354_alter_vsa_issue_first_mentioned_in_commit_value_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('alter_vsa_issue_first_mentioned_in_commit_value') +require_migration! RSpec.describe AlterVsaIssueFirstMentionedInCommitValue, schema: 20210114033715 do let(:group_stages) { table(:analytics_cycle_analytics_group_stages) } diff --git a/spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb b/spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb index cb48df20d58..97438062458 100644 --- a/spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb +++ b/spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('remove_bad_dependency_proxy_manifests') +require_migration! RSpec.describe RemoveBadDependencyProxyManifests, schema: 20210128140157 do let_it_be(:namespaces) { table(:namespaces) } diff --git a/spec/migrations/20210210093901_backfill_updated_at_after_repository_storage_move_spec.rb b/spec/migrations/20210210093901_backfill_updated_at_after_repository_storage_move_spec.rb index 1932bc00cee..4a31d36e2bc 100644 --- a/spec/migrations/20210210093901_backfill_updated_at_after_repository_storage_move_spec.rb +++ b/spec/migrations/20210210093901_backfill_updated_at_after_repository_storage_move_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('backfill_updated_at_after_repository_storage_move') +require_migration! RSpec.describe BackfillUpdatedAtAfterRepositoryStorageMove, :sidekiq do let_it_be(:projects) { table(:projects) } diff --git a/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb b/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb index e525101f3a0..039ce53cac4 100644 --- a/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb +++ b/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('add_environment_scope_to_group_variables') +require_migration! RSpec.describe AddEnvironmentScopeToGroupVariables do let(:migration) { described_class.new } diff --git a/spec/migrations/20210226141517_dedup_issue_metrics_spec.rb b/spec/migrations/20210226141517_dedup_issue_metrics_spec.rb index 6068df85e2e..1b57bf0431f 100644 --- a/spec/migrations/20210226141517_dedup_issue_metrics_spec.rb +++ b/spec/migrations/20210226141517_dedup_issue_metrics_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('dedup_issue_metrics') +require_migration! RSpec.describe DedupIssueMetrics, :migration, schema: 20210205104425 do let(:namespaces) { table(:namespaces) } diff --git a/spec/migrations/20210406144743_backfill_total_tuple_count_for_batched_migrations_spec.rb b/spec/migrations/20210406144743_backfill_total_tuple_count_for_batched_migrations_spec.rb index 94ed2320c50..1f18f7e581a 100644 --- a/spec/migrations/20210406144743_backfill_total_tuple_count_for_batched_migrations_spec.rb +++ b/spec/migrations/20210406144743_backfill_total_tuple_count_for_batched_migrations_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('backfill_total_tuple_count_for_batched_migrations') +require_migration! RSpec.describe BackfillTotalTupleCountForBatchedMigrations, :migration, schema: 20210406140057 do let_it_be(:table_name) { 'projects' } diff --git a/spec/migrations/20210413132500_reschedule_artifact_expiry_backfill_again_spec.rb b/spec/migrations/20210413132500_reschedule_artifact_expiry_backfill_again_spec.rb index 78b6a71c609..e1dc7487222 100644 --- a/spec/migrations/20210413132500_reschedule_artifact_expiry_backfill_again_spec.rb +++ b/spec/migrations/20210413132500_reschedule_artifact_expiry_backfill_again_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_migration!('reschedule_artifact_expiry_backfill_again') +require_migration! RSpec.describe RescheduleArtifactExpiryBackfillAgain, :migration do let(:migration_class) { Gitlab::BackgroundMigration::BackfillArtifactExpiryDate } diff --git a/spec/migrations/20210421163509_schedule_update_jira_tracker_data_deployment_type_based_on_url_spec.rb b/spec/migrations/20210421163509_schedule_update_jira_tracker_data_deployment_type_based_on_url_spec.rb index ea0a16212dd..9a59c739ecd 100644 --- a/spec/migrations/20210421163509_schedule_update_jira_tracker_data_deployment_type_based_on_url_spec.rb +++ b/spec/migrations/20210421163509_schedule_update_jira_tracker_data_deployment_type_based_on_url_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('schedule_update_jira_tracker_data_deployment_type_based_on_url') +require_migration! RSpec.describe ScheduleUpdateJiraTrackerDataDeploymentTypeBasedOnUrl, :migration do let(:services_table) { table(:services) } diff --git a/spec/migrations/20210423160427_schedule_drop_invalid_vulnerabilities_spec.rb b/spec/migrations/20210423160427_schedule_drop_invalid_vulnerabilities_spec.rb index 3b462c884c4..faf440eb117 100644 --- a/spec/migrations/20210423160427_schedule_drop_invalid_vulnerabilities_spec.rb +++ b/spec/migrations/20210423160427_schedule_drop_invalid_vulnerabilities_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('schedule_drop_invalid_vulnerabilities') +require_migration! RSpec.describe ScheduleDropInvalidVulnerabilities, :migration do let_it_be(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } diff --git a/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb b/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb index 03ce0a430e5..598da495195 100644 --- a/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb +++ b/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb @@ -2,7 +2,7 @@ # require 'spec_helper' -require_migration!('copy_adoption_snapshot_namespace') +require_migration! RSpec.describe CopyAdoptionSnapshotNamespace, :migration, schema: 20210430124630 do let(:namespaces_table) { table(:namespaces) } diff --git a/spec/migrations/20210430135954_copy_adoption_segments_namespace_spec.rb b/spec/migrations/20210430135954_copy_adoption_segments_namespace_spec.rb index abdfd03f97e..25dfaa2e314 100644 --- a/spec/migrations/20210430135954_copy_adoption_segments_namespace_spec.rb +++ b/spec/migrations/20210430135954_copy_adoption_segments_namespace_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_migration!('copy_adoption_segments_namespace') +require_migration! RSpec.describe CopyAdoptionSegmentsNamespace, :migration do let(:namespaces_table) { table(:namespaces) } diff --git a/spec/migrations/20210503105845_add_project_value_stream_id_to_project_stages_spec.rb b/spec/migrations/20210503105845_add_project_value_stream_id_to_project_stages_spec.rb index 4969d82d183..187b9115ba7 100644 --- a/spec/migrations/20210503105845_add_project_value_stream_id_to_project_stages_spec.rb +++ b/spec/migrations/20210503105845_add_project_value_stream_id_to_project_stages_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_migration!('add_project_value_stream_id_to_project_stages') +require_migration! RSpec.describe AddProjectValueStreamIdToProjectStages, schema: 20210503105022 do let(:stages) { table(:analytics_cycle_analytics_project_stages) } diff --git a/spec/migrations/20210511142748_schedule_drop_invalid_vulnerabilities2_spec.rb b/spec/migrations/20210511142748_schedule_drop_invalid_vulnerabilities2_spec.rb index 969a2e58947..dd557c833f3 100644 --- a/spec/migrations/20210511142748_schedule_drop_invalid_vulnerabilities2_spec.rb +++ b/spec/migrations/20210511142748_schedule_drop_invalid_vulnerabilities2_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('schedule_drop_invalid_vulnerabilities2') +require_migration! RSpec.describe ScheduleDropInvalidVulnerabilities2, :migration do let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } diff --git a/spec/migrations/20210514063252_schedule_cleanup_orphaned_lfs_objects_projects_spec.rb b/spec/migrations/20210514063252_schedule_cleanup_orphaned_lfs_objects_projects_spec.rb index b7524ee0bff..4ac4af19eb9 100644 --- a/spec/migrations/20210514063252_schedule_cleanup_orphaned_lfs_objects_projects_spec.rb +++ b/spec/migrations/20210514063252_schedule_cleanup_orphaned_lfs_objects_projects_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('schedule_cleanup_orphaned_lfs_objects_projects') +require_migration! RSpec.describe ScheduleCleanupOrphanedLfsObjectsProjects, schema: 20210511165250 do let(:lfs_objects_projects) { table(:lfs_objects_projects) } diff --git a/spec/migrations/20210601073400_fix_total_stage_in_vsa_spec.rb b/spec/migrations/20210601073400_fix_total_stage_in_vsa_spec.rb index 36d85d1f745..fa4b747aaed 100644 --- a/spec/migrations/20210601073400_fix_total_stage_in_vsa_spec.rb +++ b/spec/migrations/20210601073400_fix_total_stage_in_vsa_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('fix_total_stage_in_vsa') +require_migration! RSpec.describe FixTotalStageInVsa, :migration, schema: 20210518001450 do let(:namespaces) { table(:namespaces) } diff --git a/spec/migrations/20210601080039_group_protected_environments_add_index_and_constraint_spec.rb b/spec/migrations/20210601080039_group_protected_environments_add_index_and_constraint_spec.rb index d3154596b26..8d45f571969 100644 --- a/spec/migrations/20210601080039_group_protected_environments_add_index_and_constraint_spec.rb +++ b/spec/migrations/20210601080039_group_protected_environments_add_index_and_constraint_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('group_protected_environments_add_index_and_constraint') +require_migration! RSpec.describe GroupProtectedEnvironmentsAddIndexAndConstraint do let(:migration) { described_class.new } diff --git a/spec/migrations/20210603222333_remove_builds_email_service_from_services_spec.rb b/spec/migrations/20210603222333_remove_builds_email_service_from_services_spec.rb index c457be79834..14aa4fe8da7 100644 --- a/spec/migrations/20210603222333_remove_builds_email_service_from_services_spec.rb +++ b/spec/migrations/20210603222333_remove_builds_email_service_from_services_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_migration!('remove_builds_email_service_from_services') +require_migration! RSpec.describe RemoveBuildsEmailServiceFromServices do let(:namespaces) { table(:namespaces) } diff --git a/spec/migrations/20210610153556_delete_legacy_operations_feature_flags_spec.rb b/spec/migrations/20210610153556_delete_legacy_operations_feature_flags_spec.rb index 4f621d0670c..17599e75947 100644 --- a/spec/migrations/20210610153556_delete_legacy_operations_feature_flags_spec.rb +++ b/spec/migrations/20210610153556_delete_legacy_operations_feature_flags_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_migration!('delete_legacy_operations_feature_flags') +require_migration! RSpec.describe DeleteLegacyOperationsFeatureFlags do let(:namespace) { table(:namespaces).create!(name: 'foo', path: 'bar') } diff --git a/spec/migrations/2021061716138_cascade_delete_freeze_periods_spec.rb b/spec/migrations/2021061716138_cascade_delete_freeze_periods_spec.rb index fd664d99f06..d35184e78a8 100644 --- a/spec/migrations/2021061716138_cascade_delete_freeze_periods_spec.rb +++ b/spec/migrations/2021061716138_cascade_delete_freeze_periods_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_migration!('cascade_delete_freeze_periods') +require_migration! RSpec.describe CascadeDeleteFreezePeriods do let(:namespace) { table(:namespaces).create!(name: 'deploy_freeze', path: 'deploy_freeze') } diff --git a/spec/migrations/20210708130419_reschedule_merge_request_diff_users_background_migration_spec.rb b/spec/migrations/20210708130419_reschedule_merge_request_diff_users_background_migration_spec.rb index 9cc454662f9..7a281611650 100644 --- a/spec/migrations/20210708130419_reschedule_merge_request_diff_users_background_migration_spec.rb +++ b/spec/migrations/20210708130419_reschedule_merge_request_diff_users_background_migration_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration! 'reschedule_merge_request_diff_users_background_migration' +require_migration! RSpec.describe RescheduleMergeRequestDiffUsersBackgroundMigration, :migration do let(:migration) { described_class.new } diff --git a/spec/migrations/20210722042939_update_issuable_slas_where_issue_closed_spec.rb b/spec/migrations/20210722042939_update_issuable_slas_where_issue_closed_spec.rb index a0aae00776d..63802acceb5 100644 --- a/spec/migrations/20210722042939_update_issuable_slas_where_issue_closed_spec.rb +++ b/spec/migrations/20210722042939_update_issuable_slas_where_issue_closed_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('update_issuable_slas_where_issue_closed') +require_migration! RSpec.describe UpdateIssuableSlasWhereIssueClosed, :migration do let(:namespaces) { table(:namespaces) } diff --git a/spec/migrations/20210722150102_operations_feature_flags_correct_flexible_rollout_values_spec.rb b/spec/migrations/20210722150102_operations_feature_flags_correct_flexible_rollout_values_spec.rb index 130ad45ffc1..94af2bb1e9a 100644 --- a/spec/migrations/20210722150102_operations_feature_flags_correct_flexible_rollout_values_spec.rb +++ b/spec/migrations/20210722150102_operations_feature_flags_correct_flexible_rollout_values_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_migration!('operations_feature_flags_correct_flexible_rollout_values') +require_migration! RSpec.describe OperationsFeatureFlagsCorrectFlexibleRolloutValues, :migration do let_it_be(:strategies) { table(:operations_strategies) } diff --git a/spec/migrations/20210804150320_create_base_work_item_types_spec.rb b/spec/migrations/20210804150320_create_base_work_item_types_spec.rb index 9ba29637e00..34ea7f53f51 100644 --- a/spec/migrations/20210804150320_create_base_work_item_types_spec.rb +++ b/spec/migrations/20210804150320_create_base_work_item_types_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('create_base_work_item_types') +require_migration! RSpec.describe CreateBaseWorkItemTypes, :migration do let!(:work_item_types) { table(:work_item_types) } diff --git a/spec/migrations/20210805192450_update_trial_plans_ci_daily_pipeline_schedule_triggers_spec.rb b/spec/migrations/20210805192450_update_trial_plans_ci_daily_pipeline_schedule_triggers_spec.rb index 819120d43ef..0b2f76baf1a 100644 --- a/spec/migrations/20210805192450_update_trial_plans_ci_daily_pipeline_schedule_triggers_spec.rb +++ b/spec/migrations/20210805192450_update_trial_plans_ci_daily_pipeline_schedule_triggers_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_migration!('update_trial_plans_ci_daily_pipeline_schedule_triggers') +require_migration! RSpec.describe UpdateTrialPlansCiDailyPipelineScheduleTriggers, :migration do let!(:plans) { table(:plans) } diff --git a/spec/migrations/20210811122206_update_external_project_bots_spec.rb b/spec/migrations/20210811122206_update_external_project_bots_spec.rb index a9c7b485cc6..365fb8e3218 100644 --- a/spec/migrations/20210811122206_update_external_project_bots_spec.rb +++ b/spec/migrations/20210811122206_update_external_project_bots_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('update_external_project_bots') +require_migration! RSpec.describe UpdateExternalProjectBots, :migration do def create_user(**extra_options) diff --git a/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb b/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb index d87f952b5da..29f554a003b 100644 --- a/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb +++ b/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('backfill_projects_with_coverage') +require_migration! RSpec.describe BackfillProjectsWithCoverage do let(:projects) { table(:projects) } diff --git a/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb b/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb index b1751216732..4ad4bea058b 100644 --- a/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb +++ b/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('drop_temporary_columns_and_triggers_for_ci_builds_runner_session') +require_migration! RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildsRunnerSession, :migration do let(:ci_builds_runner_session_table) { table(:ci_builds_runner_session) } diff --git a/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb b/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb index c23110750c3..3c8c55ccb80 100644 --- a/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb +++ b/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('upsert_base_work_item_types') +require_migration! RSpec.describe UpsertBaseWorkItemTypes, :migration do let!(:work_item_types) { table(:work_item_types) } diff --git a/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb b/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb index 1b35982c41d..4ec3c5b7211 100644 --- a/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb +++ b/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('drop_temporary_columns_and_triggers_for_ci_build_needs') +require_migration! RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildNeeds do let(:ci_build_needs_table) { table(:ci_build_needs) } diff --git a/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb b/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb index 8d46ba7eb58..f1408e4ecab 100644 --- a/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb +++ b/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('drop_temporary_columns_and_triggers_for_ci_build_trace_chunks') +require_migration! RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildTraceChunks do let(:ci_build_trace_chunks_table) { table(:ci_build_trace_chunks) } diff --git a/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb b/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb index 2e7ce733373..e4385e501b2 100644 --- a/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb +++ b/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('drop_temporary_columns_and_triggers_for_taggings') +require_migration! RSpec.describe DropTemporaryColumnsAndTriggersForTaggings do let(:taggings_table) { table(:taggings) } diff --git a/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb b/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb index ece5ed8251d..194832fbc43 100644 --- a/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb +++ b/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('cleanup_bigint_conversion_for_ci_builds_metadata') +require_migration! RSpec.describe CleanupBigintConversionForCiBuildsMetadata do let(:ci_builds_metadata) { table(:ci_builds_metadata) } diff --git a/spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb b/spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb index 362b4be1bc6..c0f56da7b4f 100644 --- a/spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb +++ b/spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('finalize_ci_builds_bigint_conversion') +require_migration! RSpec.describe FinalizeCiBuildsBigintConversion, :migration, schema: 20210907182359 do context 'with an unexpected FK fk_3f0c88d7dc' do diff --git a/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb b/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb index 46a6d8d92ec..c90eabbe4eb 100644 --- a/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb +++ b/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('update_report_type_for_existing_approval_project_rules') +require_migration! RSpec.describe UpdateReportTypeForExistingApprovalProjectRules, :migration do using RSpec::Parameterized::TableSyntax diff --git a/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb b/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb index 0d0f6a3df67..2b755dfe11c 100644 --- a/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb +++ b/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('cleanup_orphan_project_access_tokens') +require_migration! RSpec.describe CleanupOrphanProjectAccessTokens, :migration do def create_user(**extra_options) diff --git a/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb b/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb index ee71322433d..cedc62a6565 100644 --- a/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb +++ b/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('cleanup_bigint_conversion_for_ci_builds') +require_migration! RSpec.describe CleanupBigintConversionForCiBuilds do let(:ci_builds) { table(:ci_builds) } diff --git a/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb b/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb index cf326cf0c0a..a6eede8a8f1 100644 --- a/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb +++ b/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('drop_int4_columns_for_ci_job_artifacts') +require_migration! RSpec.describe DropInt4ColumnsForCiJobArtifacts do let(:ci_job_artifacts) { table(:ci_job_artifacts) } diff --git a/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb b/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb index 00b922ee4f8..730c9ade1fb 100644 --- a/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb +++ b/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('drop_int4_column_for_ci_sources_pipelines') +require_migration! RSpec.describe DropInt4ColumnForCiSourcesPipelines do let(:ci_sources_pipelines) { table(:ci_sources_pipelines) } diff --git a/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb b/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb index 412556fc283..e460612a7d5 100644 --- a/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb +++ b/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('drop_int4_column_for_events') +require_migration! RSpec.describe DropInt4ColumnForEvents do let(:events) { table(:events) } diff --git a/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb b/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb index 2b286e3e5e0..8c89cd19f7f 100644 --- a/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb +++ b/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('drop_int4_column_for_push_event_payloads') +require_migration! RSpec.describe DropInt4ColumnForPushEventPayloads do let(:push_event_payloads) { table(:push_event_payloads) } diff --git a/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb b/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb index d07d9a71b06..09ce0858b12 100644 --- a/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb +++ b/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration!('schedule_populate_topics_total_projects_count_cache') +require_migration! RSpec.describe SchedulePopulateTopicsTotalProjectsCountCache do let(:topics) { table(:topics) } diff --git a/spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb b/spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb new file mode 100644 index 00000000000..910e6d1d91b --- /dev/null +++ b/spec/migrations/20211012134316_clean_up_migrate_merge_request_diff_commit_users_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! 'clean_up_migrate_merge_request_diff_commit_users' + +RSpec.describe CleanUpMigrateMergeRequestDiffCommitUsers, :migration do + describe '#up' do + context 'when there are pending jobs' do + it 'processes the jobs immediately' do + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: 'MigrateMergeRequestDiffCommitUsers', + status: :pending, + arguments: [10, 20] + ) + + spy = Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers + migration = described_class.new + + allow(Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers) + .to receive(:new) + .and_return(spy) + + expect(migration).to receive(:say) + expect(spy).to receive(:perform).with(10, 20) + + migration.up + end + end + + context 'when all jobs are completed' do + it 'does nothing' do + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: 'MigrateMergeRequestDiffCommitUsers', + status: :succeeded, + arguments: [10, 20] + ) + + migration = described_class.new + + expect(migration).not_to receive(:say) + expect(Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers) + .not_to receive(:new) + + migration.up + end + end + end +end diff --git a/spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb b/spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb new file mode 100644 index 00000000000..95c5be2fc30 --- /dev/null +++ b/spec/migrations/20211018152654_schedule_remove_duplicate_vulnerabilities_findings3_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true +require 'spec_helper' +require_migration!('schedule_remove_duplicate_vulnerabilities_findings3') + +RSpec.describe ScheduleRemoveDuplicateVulnerabilitiesFindings3, :migration do + let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } + let(:users) { table(:users) } + let(:user) { create_user! } + let(:project) { table(:projects).create!(id: 14219619, namespace_id: namespace.id) } + let(:scanners) { table(:vulnerability_scanners) } + let!(:scanner1) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } + let!(:scanner2) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } + let!(:scanner3) { scanners.create!(project_id: project.id, external_id: 'test 3', name: 'test scanner 3') } + let!(:unrelated_scanner) { scanners.create!(project_id: project.id, external_id: 'unreleated_scanner', name: 'unrelated scanner') } + let(:vulnerabilities) { table(:vulnerabilities) } + let(:vulnerability_findings) { table(:vulnerability_occurrences) } + let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } + let(:vulnerability_identifier) do + vulnerability_identifiers.create!( + id: 1244459, + project_id: project.id, + external_type: 'vulnerability-identifier', + external_id: 'vulnerability-identifier', + fingerprint: '0a203e8cd5260a1948edbedc76c7cb91ad6a2e45', + name: 'vulnerability identifier') + end + + let!(:vulnerability_for_first_duplicate) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:first_finding_duplicate) do + create_finding!( + id: 5606961, + uuid: "bd95c085-71aa-51d7-9bb6-08ae669c262e", + vulnerability_id: vulnerability_for_first_duplicate.id, + report_type: 0, + location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75', + primary_identifier_id: vulnerability_identifier.id, + scanner_id: scanner1.id, + project_id: project.id + ) + end + + let!(:vulnerability_for_second_duplicate) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:second_finding_duplicate) do + create_finding!( + id: 8765432, + uuid: "5b714f58-1176-5b26-8fd5-e11dfcb031b5", + vulnerability_id: vulnerability_for_second_duplicate.id, + report_type: 0, + location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75', + primary_identifier_id: vulnerability_identifier.id, + scanner_id: scanner2.id, + project_id: project.id + ) + end + + let!(:vulnerability_for_third_duplicate) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:third_finding_duplicate) do + create_finding!( + id: 8832995, + uuid: "cfe435fa-b25b-5199-a56d-7b007cc9e2d4", + vulnerability_id: vulnerability_for_third_duplicate.id, + report_type: 0, + location_fingerprint: '00049d5119c2cb3bfb3d1ee1f6e031fe925aed75', + primary_identifier_id: vulnerability_identifier.id, + scanner_id: scanner3.id, + project_id: project.id + ) + end + + let!(:unrelated_finding) do + create_finding!( + id: 9999999, + uuid: "unreleated_finding", + vulnerability_id: nil, + report_type: 1, + location_fingerprint: 'random_location_fingerprint', + primary_identifier_id: vulnerability_identifier.id, + scanner_id: unrelated_scanner.id, + project_id: project.id + ) + end + + before do + stub_const("#{described_class}::BATCH_SIZE", 1) + end + + around do |example| + freeze_time { Sidekiq::Testing.fake! { example.run } } + end + + it 'schedules background migration' do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq(4) + expect(described_class::MIGRATION).to be_scheduled_migration(first_finding_duplicate.id, first_finding_duplicate.id) + expect(described_class::MIGRATION).to be_scheduled_migration(second_finding_duplicate.id, second_finding_duplicate.id) + expect(described_class::MIGRATION).to be_scheduled_migration(third_finding_duplicate.id, third_finding_duplicate.id) + expect(described_class::MIGRATION).to be_scheduled_migration(unrelated_finding.id, unrelated_finding.id) + end + + private + + def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0) + vulnerabilities.create!( + project_id: project_id, + author_id: author_id, + title: title, + severity: severity, + confidence: confidence, + report_type: report_type + ) + end + + # rubocop:disable Metrics/ParameterLists + def create_finding!( + id: nil, + vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, + name: "test", severity: 7, confidence: 7, report_type: 0, + project_fingerprint: '123qweasdzxc', location_fingerprint: 'test', + metadata_version: 'test', raw_metadata: 'test', uuid: 'test') + vulnerability_findings.create!({ + id: id, + vulnerability_id: vulnerability_id, + project_id: project_id, + name: name, + severity: severity, + confidence: confidence, + report_type: report_type, + project_fingerprint: project_fingerprint, + scanner_id: scanner_id, + primary_identifier_id: vulnerability_identifier.id, + location_fingerprint: location_fingerprint, + metadata_version: metadata_version, + raw_metadata: raw_metadata, + uuid: uuid + }.compact) + end + # rubocop:enable Metrics/ParameterLists + + def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.zone.now, confirmed_at: Time.zone.now) + users.create!( + name: name, + email: email, + username: name, + projects_limit: 0, + user_type: user_type, + confirmed_at: confirmed_at + ) + end +end diff --git a/spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb b/spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb new file mode 100644 index 00000000000..6511f554436 --- /dev/null +++ b/spec/migrations/20211028155449_schedule_fix_merge_request_diff_commit_users_migration_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! 'schedule_fix_merge_request_diff_commit_users_migration' + +RSpec.describe ScheduleFixMergeRequestDiffCommitUsersMigration, :migration do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } + + describe '#up' do + it 'does nothing when there are no projects to correct' do + migration.up + + expect(Gitlab::Database::BackgroundMigrationJob.count).to be_zero + end + + it 'schedules imported projects created after July' do + project = projects.create!( + namespace_id: namespace.id, + import_type: 'gitlab_project', + created_at: '2021-08-01' + ) + + expect(migration) + .to receive(:migrate_in) + .with(2.minutes, 'FixMergeRequestDiffCommitUsers', [project.id]) + + migration.up + + expect(Gitlab::Database::BackgroundMigrationJob.count).to eq(1) + + job = Gitlab::Database::BackgroundMigrationJob.first + + expect(job.class_name).to eq('FixMergeRequestDiffCommitUsers') + expect(job.arguments).to eq([project.id]) + end + + it 'ignores projects imported before July' do + projects.create!( + namespace_id: namespace.id, + import_type: 'gitlab_project', + created_at: '2020-08-01' + ) + + migration.up + + expect(Gitlab::Database::BackgroundMigrationJob.count).to be_zero + end + + it 'ignores projects that are not imported' do + projects.create!( + namespace_id: namespace.id, + created_at: '2021-08-01' + ) + + migration.up + + expect(Gitlab::Database::BackgroundMigrationJob.count).to be_zero + end + end +end diff --git a/spec/migrations/add_default_value_stream_to_groups_with_group_stages_spec.rb b/spec/migrations/add_default_value_stream_to_groups_with_group_stages_spec.rb deleted file mode 100644 index f21acbc56df..00000000000 --- a/spec/migrations/add_default_value_stream_to_groups_with_group_stages_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AddDefaultValueStreamToGroupsWithGroupStages, schema: 20200624142207 do - let(:groups) { table(:namespaces) } - let(:group_stages) { table(:analytics_cycle_analytics_group_stages) } - let(:value_streams) { table(:analytics_cycle_analytics_group_value_streams) } - - let!(:group) { groups.create!(name: 'test', path: 'path', type: 'Group') } - let!(:group_stage) { group_stages.create!(name: 'test', group_id: group.id, start_event_identifier: 1, end_event_identifier: 2) } - - describe '#up' do - it 'creates default value stream record for the group' do - migrate! - - group_value_streams = value_streams.where(group_id: group.id) - expect(group_value_streams.size).to eq(1) - - value_stream = group_value_streams.first - expect(value_stream.name).to eq('default') - end - - it 'migrates existing stages to the default value stream' do - migrate! - - group_stage.reload - - value_stream = value_streams.find_by(group_id: group.id, name: 'default') - expect(group_stage.group_value_stream_id).to eq(value_stream.id) - end - end - - describe '#down' do - it 'sets the group_value_stream_id to nil' do - described_class.new.down - - group_stage.reload - - expect(group_stage.group_value_stream_id).to be_nil - end - end -end diff --git a/spec/migrations/add_deploy_token_type_to_deploy_tokens_spec.rb b/spec/migrations/add_deploy_token_type_to_deploy_tokens_spec.rb deleted file mode 100644 index f90bfcd313c..00000000000 --- a/spec/migrations/add_deploy_token_type_to_deploy_tokens_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AddDeployTokenTypeToDeployTokens do - let(:deploy_tokens) { table(:deploy_tokens) } - let(:deploy_token) do - deploy_tokens.create!(name: 'token_test', - username: 'gitlab+deploy-token-1', - token_encrypted: 'dr8rPXwM+Mbs2p3Bg1+gpnXqrnH/wu6vaHdcc7A3isPR67WB', - read_repository: true, - expires_at: Time.now + 1.year) - end - - it 'updates the deploy_token_type column to 2' do - expect(deploy_token).not_to respond_to(:deploy_token_type) - - migrate! - - deploy_token.reload - expect(deploy_token.deploy_token_type).to eq(2) - end -end diff --git a/spec/migrations/add_incident_settings_to_all_existing_projects_spec.rb b/spec/migrations/add_incident_settings_to_all_existing_projects_spec.rb deleted file mode 100644 index 3e0bc64bb23..00000000000 --- a/spec/migrations/add_incident_settings_to_all_existing_projects_spec.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AddIncidentSettingsToAllExistingProjects, :migration do - let(:project_incident_management_settings) { table(:project_incident_management_settings) } - let(:labels) { table(:labels) } - let(:label_links) { table(:label_links) } - let(:issues) { table(:issues) } - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - - RSpec.shared_examples 'setting not added' do - it 'does not add settings' do - migrate! - - expect { migrate! }.not_to change { IncidentManagement::ProjectIncidentManagementSetting.count } - end - end - - RSpec.shared_examples 'project has no incident settings' do - it 'has no settings' do - migrate! - - expect(settings).to eq(nil) - end - end - - RSpec.shared_examples 'no change to incident settings' do - it 'does not change existing settings' do - migrate! - - expect(settings.create_issue).to eq(existing_create_issue) - end - end - - RSpec.shared_context 'with incident settings' do - let(:existing_create_issue) { false } - before do - project_incident_management_settings.create!( - project_id: project.id, - create_issue: existing_create_issue - ) - end - end - - describe 'migrate!' do - let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(namespace_id: namespace.id) } - let(:settings) { project_incident_management_settings.find_by(project_id: project.id) } - - context 'when project does not have incident label' do - context 'does not have incident settings' do - include_examples 'setting not added' - include_examples 'project has no incident settings' - end - - context 'and has incident settings' do - include_context 'with incident settings' - - include_examples 'setting not added' - include_examples 'no change to incident settings' - end - end - - context 'when project has incident labels' do - before do - issue = issues.create!(project_id: project.id) - incident_label_attrs = IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES - incident_label = labels.create!(project_id: project.id, **incident_label_attrs) - label_links.create!(target_id: issue.id, label_id: incident_label.id, target_type: 'Issue') - end - - context 'when project has incident settings' do - include_context 'with incident settings' - - include_examples 'setting not added' - include_examples 'no change to incident settings' - end - - context 'does not have incident settings' do - it 'adds incident settings with old defaults' do - migrate! - - expect(settings.create_issue).to eq(true) - expect(settings.send_email).to eq(false) - expect(settings.issue_template_key).to eq(nil) - end - end - end - end -end diff --git a/spec/migrations/add_open_source_plan_spec.rb b/spec/migrations/add_open_source_plan_spec.rb new file mode 100644 index 00000000000..04b26662f82 --- /dev/null +++ b/spec/migrations/add_open_source_plan_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe AddOpenSourcePlan, :migration do + describe '#up' do + before do + allow(Gitlab).to receive(:dev_env_or_com?).and_return true + end + + it 'creates 1 entry within the plans table' do + expect { migrate! }.to change { AddOpenSourcePlan::Plan.count }.by 1 + expect(AddOpenSourcePlan::Plan.last.name).to eql('opensource') + end + + it 'creates 1 entry for plan limits' do + expect { migrate! }.to change { AddOpenSourcePlan::PlanLimits.count }.by 1 + end + + context 'when the plan limits for gold and silver exists' do + before do + table(:plans).create!(id: 1, name: 'ultimate', title: 'Ultimate') + table(:plan_limits).create!(id: 1, plan_id: 1, storage_size_limit: 2000) + end + + it 'duplicates the gold and silvers plan limits entries' do + migrate! + + opensource_limits = AddOpenSourcePlan::Plan.find_by(name: 'opensource').limits + expect(opensource_limits.storage_size_limit).to be 2000 + end + end + + context 'when the instance is not SaaS' do + before do + allow(Gitlab).to receive(:dev_env_or_com?).and_return false + end + + it 'does not create plans and plan limits and returns' do + expect { migrate! }.not_to change { AddOpenSourcePlan::Plan.count } + end + end + end + + describe '#down' do + before do + table(:plans).create!(id: 3, name: 'other') + table(:plan_limits).create!(plan_id: 3) + end + + context 'when the instance is SaaS' do + before do + allow(Gitlab).to receive(:dev_env_or_com?).and_return true + end + + it 'removes the newly added opensource entry' do + migrate! + + expect { described_class.new.down }.to change { AddOpenSourcePlan::Plan.count }.by(-1) + expect(AddOpenSourcePlan::Plan.find_by(name: 'opensource')).to be_nil + + other_plan = AddOpenSourcePlan::Plan.find_by(name: 'other') + expect(other_plan).to be_persisted + expect(AddOpenSourcePlan::PlanLimits.count).to eq(1) + expect(AddOpenSourcePlan::PlanLimits.first.plan_id).to eq(other_plan.id) + end + end + + context 'when the instance is not SaaS' do + before do + allow(Gitlab).to receive(:dev_env_or_com?).and_return false + table(:plans).create!(id: 1, name: 'opensource', title: 'Open Source Program') + table(:plan_limits).create!(id: 1, plan_id: 1) + end + + it 'does not delete plans and plan limits and returns' do + migrate! + + expect { described_class.new.down }.not_to change { AddOpenSourcePlan::Plan.count } + expect(AddOpenSourcePlan::PlanLimits.count).to eq(2) + end + end + end +end diff --git a/spec/migrations/add_partial_index_to_ci_builds_table_on_user_id_name_spec.rb b/spec/migrations/add_partial_index_to_ci_builds_table_on_user_id_name_spec.rb deleted file mode 100644 index ab4d6f43797..00000000000 --- a/spec/migrations/add_partial_index_to_ci_builds_table_on_user_id_name_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AddPartialIndexToCiBuildsTableOnUserIdName do - let(:migration) { described_class.new } - - describe '#up' do - it 'creates temporary partial index on type' do - expect { migration.up }.to change { migration.index_exists?(:ci_builds, [:user_id, :name], name: described_class::INDEX_NAME) }.from(false).to(true) - end - end - - describe '#down' do - it 'removes temporary partial index on type' do - migration.up - - expect { migration.down }.to change { migration.index_exists?(:ci_builds, [:user_id, :name], name: described_class::INDEX_NAME) }.from(true).to(false) - end - end -end diff --git a/spec/migrations/add_repository_storages_weighted_to_application_settings_spec.rb b/spec/migrations/add_repository_storages_weighted_to_application_settings_spec.rb deleted file mode 100644 index bc4c510fea3..00000000000 --- a/spec/migrations/add_repository_storages_weighted_to_application_settings_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AddRepositoryStoragesWeightedToApplicationSettings, :migration do - let(:storages) { { "foo" => {}, "baz" => {} } } - let(:application_settings) do - table(:application_settings).tap do |klass| - klass.class_eval do - serialize :repository_storages - end - end - end - - before do - allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) - end - - let(:application_setting) { application_settings.create! } - let(:repository_storages) { ["foo"] } - - it 'populates repository_storages_weighted properly' do - application_setting.repository_storages = repository_storages - application_setting.save! - - migrate! - - expect(application_settings.find(application_setting.id).repository_storages_weighted).to eq({ "foo" => 100, "baz" => 0 }) - end -end diff --git a/spec/migrations/add_temporary_partial_index_on_project_id_to_services_spec.rb b/spec/migrations/add_temporary_partial_index_on_project_id_to_services_spec.rb deleted file mode 100644 index dae0241b895..00000000000 --- a/spec/migrations/add_temporary_partial_index_on_project_id_to_services_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AddTemporaryPartialIndexOnProjectIdToServices do - let(:migration) { described_class.new } - - describe '#up' do - it 'creates temporary partial index on type' do - expect { migration.up }.to change { migration.index_exists?(:services, :project_id, name: described_class::INDEX_NAME) }.from(false).to(true) - end - end - - describe '#down' do - it 'removes temporary partial index on type' do - migration.up - - expect { migration.down }.to change { migration.index_exists?(:services, :project_id, name: described_class::INDEX_NAME) }.from(true).to(false) - end - end -end diff --git a/spec/migrations/backfill_imported_snippet_repositories_spec.rb b/spec/migrations/backfill_imported_snippet_repositories_spec.rb deleted file mode 100644 index 7052433c66d..00000000000 --- a/spec/migrations/backfill_imported_snippet_repositories_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe BackfillImportedSnippetRepositories do - let(:users) { table(:users) } - let(:snippets) { table(:snippets) } - let(:user) { users.create!(id: 1, email: 'user@example.com', projects_limit: 10, username: 'test', name: 'Test', state: 'active') } - - def create_snippet(id) - params = { - id: id, - type: 'PersonalSnippet', - author_id: user.id, - file_name: 'foo', - content: 'bar' - } - - snippets.create!(params) - end - - it 'correctly schedules background migrations' do - create_snippet(1) - create_snippet(2) - create_snippet(3) - create_snippet(5) - create_snippet(7) - create_snippet(8) - create_snippet(10) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes, 1, 3) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(4.minutes, 5, 5) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(6.minutes, 7, 8) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(8.minutes, 10, 10) - - expect(BackgroundMigrationWorker.jobs.size).to eq(4) - end - end - end -end diff --git a/spec/migrations/backfill_operations_feature_flags_iid_spec.rb b/spec/migrations/backfill_operations_feature_flags_iid_spec.rb deleted file mode 100644 index 3c400840f98..00000000000 --- a/spec/migrations/backfill_operations_feature_flags_iid_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe BackfillOperationsFeatureFlagsIid do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:flags) { table(:operations_feature_flags) } - - def setup - namespace = namespaces.create!(name: 'foo', path: 'foo') - projects.create!(namespace_id: namespace.id) - end - - it 'migrates successfully when there are no flags in the database' do - setup - - disable_migrations_output { migrate! } - - expect(flags.count).to eq(0) - end - - it 'migrates successfully with a row in the table in both FOSS and EE' do - project = setup - flags.create!(project_id: project.id, active: true, name: 'test_flag') - - disable_migrations_output { migrate! } - - expect(flags.count).to eq(1) - end -end diff --git a/spec/migrations/backfill_snippet_repositories_spec.rb b/spec/migrations/backfill_snippet_repositories_spec.rb deleted file mode 100644 index 64cfc9cc57b..00000000000 --- a/spec/migrations/backfill_snippet_repositories_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe BackfillSnippetRepositories do - let(:users) { table(:users) } - let(:snippets) { table(:snippets) } - let(:user) { users.create!(id: 1, email: 'user@example.com', projects_limit: 10, username: 'test', name: 'Test', state: 'active') } - - def create_snippet(id) - params = { - id: id, - type: 'PersonalSnippet', - author_id: user.id, - file_name: 'foo', - content: 'bar' - } - - snippets.create!(params) - end - - it 'correctly schedules background migrations' do - create_snippet(1) - create_snippet(2) - create_snippet(3) - - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(3.minutes, 1, 2) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(6.minutes, 3, 3) - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end -end diff --git a/spec/migrations/backfill_status_page_published_incidents_spec.rb b/spec/migrations/backfill_status_page_published_incidents_spec.rb deleted file mode 100644 index fa4bb182362..00000000000 --- a/spec/migrations/backfill_status_page_published_incidents_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe BackfillStatusPagePublishedIncidents, :migration do - subject(:migration) { described_class.new } - - describe '#up' do - let(:projects) { table(:projects) } - let(:status_page_settings) { table(:status_page_settings) } - let(:issues) { table(:issues) } - let(:incidents) { table(:status_page_published_incidents) } - - let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab') } - let(:project_without_status_page) { projects.create!(namespace_id: namespace.id) } - let(:enabled_project) { projects.create!(namespace_id: namespace.id) } - let(:disabled_project) { projects.create!(namespace_id: namespace.id) } - - let!(:enabled_setting) { status_page_settings.create!(enabled: true, project_id: enabled_project.id, **status_page_setting_attrs) } - let!(:disabled_setting) { status_page_settings.create!(enabled: false, project_id: disabled_project.id, **status_page_setting_attrs) } - - let!(:published_issue) { issues.create!(confidential: false, project_id: enabled_project.id) } - let!(:nonpublished_issue_1) { issues.create!(confidential: true, project_id: enabled_project.id) } - let!(:nonpublished_issue_2) { issues.create!(confidential: false, project_id: disabled_project.id) } - let!(:nonpublished_issue_3) { issues.create!(confidential: false, project_id: project_without_status_page.id) } - - let(:current_time) { Time.current.change(usec: 0) } - let(:status_page_setting_attrs) do - { - aws_s3_bucket_name: 'bucket', - aws_region: 'region', - aws_access_key: 'key', - encrypted_aws_secret_key: 'abc123', - encrypted_aws_secret_key_iv: 'abc123' - } - end - - it 'creates a StatusPage::PublishedIncident record for each published issue' do - travel_to(current_time) do - expect(incidents.all).to be_empty - - migrate! - - incident = incidents.first - - expect(incidents.count).to eq(1) - expect(incident.issue_id).to eq(published_issue.id) - expect(incident.created_at).to eq(current_time) - expect(incident.updated_at).to eq(current_time) - end - end - end -end diff --git a/spec/migrations/backfill_user_namespace_spec.rb b/spec/migrations/backfill_user_namespace_spec.rb new file mode 100644 index 00000000000..094aec82e9c --- /dev/null +++ b/spec/migrations/backfill_user_namespace_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe BackfillUserNamespace do + let_it_be(:migration) { described_class::MIGRATION } + + describe '#up' do + it 'schedules background jobs for each batch of namespaces' do + migrate! + + expect(migration).to have_scheduled_batched_migration( + table_name: :namespaces, + column_name: :id, + interval: described_class::INTERVAL + ) + end + end + + describe '#down' do + it 'deletes all batched migration records' do + migrate! + schema_migrate_down! + + expect(migration).not_to have_scheduled_batched_migration + end + end +end diff --git a/spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb b/spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb deleted file mode 100644 index 702f2e6d9bd..00000000000 --- a/spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe CapDesignsFilenameLengthToNewLimit, :migration, schema: 20200528125905 do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:issues) { table(:issues) } - let(:designs) { table(:design_management_designs) } - - let(:filename_below_limit) { generate_filename(254) } - let(:filename_at_limit) { generate_filename(255) } - let(:filename_above_limit) { generate_filename(256) } - - let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(name: 'gitlab', path: 'gitlab-org/gitlab', namespace_id: namespace.id) } - let!(:issue) { issues.create!(description: 'issue', project_id: project.id) } - - def generate_filename(length, extension: '.png') - name = 'a' * (length - extension.length) - - "#{name}#{extension}" - end - - def create_design(filename) - designs.create!( - issue_id: issue.id, - project_id: project.id, - filename: filename - ) - end - - it 'correctly sets filenames that are above the limit' do - designs = [ - filename_below_limit, - filename_at_limit, - filename_above_limit - ].map(&method(:create_design)) - - migrate! - - designs.each(&:reload) - - expect(designs[0].filename).to eq(filename_below_limit) - expect(designs[1].filename).to eq(filename_at_limit) - expect(designs[2].filename).to eq([described_class::MODIFIED_NAME, designs[2].id, described_class::MODIFIED_EXTENSION].join) - end - - it 'runs after filename limit has been set' do - # This spec file uses the `schema:` keyword to run these tests - # against a schema version before the one that sets the limit, - # as otherwise we can't create the design data with filenames greater - # than the limit. - # - # For this test, we migrate any skipped versions up to this migration. - migration_context.migrate(20200602013901) - - create_design(filename_at_limit) - expect { create_design(filename_above_limit) }.to raise_error(ActiveRecord::StatementInvalid) - end -end diff --git a/spec/migrations/clean_grafana_url_spec.rb b/spec/migrations/clean_grafana_url_spec.rb deleted file mode 100644 index 7a81eb3058b..00000000000 --- a/spec/migrations/clean_grafana_url_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe CleanGrafanaUrl do - let(:application_settings_table) { table(:application_settings) } - - [ - 'javascript:alert(window.opener.document.location)', - ' javascript:alert(window.opener.document.location)' - ].each do |grafana_url| - it "sets grafana_url back to its default value when grafana_url is '#{grafana_url}'" do - application_settings = application_settings_table.create!(grafana_url: grafana_url) - - migrate! - - expect(application_settings.reload.grafana_url).to eq('/-/grafana') - end - end - - ['/-/grafana', '/some/relative/url', 'http://localhost:9000'].each do |grafana_url| - it "does not modify grafana_url when grafana_url is '#{grafana_url}'" do - application_settings = application_settings_table.create!(grafana_url: grafana_url) - - migrate! - - expect(application_settings.reload.grafana_url).to eq(grafana_url) - end - end - - context 'when application_settings table has no rows' do - it 'does not fail' do - migrate! - end - end -end diff --git a/spec/migrations/cleanup_empty_commit_user_mentions_spec.rb b/spec/migrations/cleanup_empty_commit_user_mentions_spec.rb deleted file mode 100644 index d128c13e212..00000000000 --- a/spec/migrations/cleanup_empty_commit_user_mentions_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe CleanupEmptyCommitUserMentions, :migration, :sidekiq do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:notes) { table(:notes) } - - let(:user) { users.create!(name: 'root', email: 'root@example.com', username: 'root', projects_limit: 0) } - let(:group) { namespaces.create!(name: 'group1', path: 'group1', owner_id: user.id) } - let(:project) { projects.create!(name: 'gitlab1', path: 'gitlab1', namespace_id: group.id, visibility_level: 0) } - - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } - let(:commit) { Commit.new(RepoHelpers.sample_commit, project) } - let(:commit_user_mentions) { table(:commit_user_mentions) } - - let!(:resource1) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: user.id, note: 'note1 for @root to check') } - let!(:resource2) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: user.id, note: 'note1 for @root to check') } - let!(:resource3) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: user.id, note: 'note1 for @root to check', system: true) } - - # this note is already migrated, as it has a record in the commit_user_mentions table - let!(:resource4) { notes.create!(note: 'note3 for @root to check', commit_id: commit.id, noteable_type: 'Commit') } - let!(:user_mention) { commit_user_mentions.create!(commit_id: commit.id, note_id: resource4.id, mentioned_users_ids: [1]) } - - # these should get cleanup, by the migration - let!(:blank_commit_user_mention1) { commit_user_mentions.create!(commit_id: commit.id, note_id: resource1.id)} - let!(:blank_commit_user_mention2) { commit_user_mentions.create!(commit_id: commit.id, note_id: resource2.id)} - let!(:blank_commit_user_mention3) { commit_user_mentions.create!(commit_id: commit.id, note_id: resource3.id)} - - it 'cleanups blank user mentions' do - expect { migrate! }.to change { commit_user_mentions.count }.by(-3) - end -end diff --git a/spec/migrations/cleanup_group_import_states_with_null_user_id_spec.rb b/spec/migrations/cleanup_group_import_states_with_null_user_id_spec.rb deleted file mode 100644 index acd6a19779d..00000000000 --- a/spec/migrations/cleanup_group_import_states_with_null_user_id_spec.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -# In order to test the CleanupGroupImportStatesWithNullUserId migration, we need -# to first create GroupImportState with NULL user_id -# and then run the migration to check that user_id was populated or record removed -# -# The problem is that the CleanupGroupImportStatesWithNullUserId migration comes -# after the NOT NULL constraint has been added with a previous migration (AddNotNullConstraintToUserOnGroupImportStates) -# That means that while testing the current class we can not insert GroupImportState records with an -# invalid user_id as constraint is blocking it from doing so -# -# To solve this problem, use SchemaVersionFinder to set schema one version prior to AddNotNullConstraintToUserOnGroupImportStates - -require 'spec_helper' -require_migration!('add_not_null_constraint_to_user_on_group_import_states') -require_migration! - -RSpec.describe CleanupGroupImportStatesWithNullUserId, :migration, - schema: MigrationHelpers::SchemaVersionFinder.migration_prior(AddNotNullConstraintToUserOnGroupImportStates) do - let(:namespaces_table) { table(:namespaces) } - let(:users_table) { table(:users) } - let(:group_import_states_table) { table(:group_import_states) } - let(:members_table) { table(:members) } - - describe 'Group import states clean up' do - context 'when user_id is present' do - it 'does not update group_import_state record' do - user_1 = users_table.create!(name: 'user1', email: 'user1@example.com', projects_limit: 1) - group_1 = namespaces_table.create!(name: 'group_1', path: 'group_1', type: 'Group') - create_member(user_id: user_1.id, type: 'GroupMember', source_type: 'Namespace', source_id: group_1.id, access_level: described_class::Group::OWNER) - group_import_state_1 = group_import_states_table.create!(group_id: group_1.id, user_id: user_1.id, status: 0) - - expect(group_import_state_1.user_id).to eq(user_1.id) - - disable_migrations_output { migrate! } - - expect(group_import_state_1.reload.user_id).to eq(user_1.id) - end - end - - context 'when user_id is missing' do - it 'updates user_id with group default owner id' do - user_2 = users_table.create!(name: 'user2', email: 'user2@example.com', projects_limit: 1) - group_2 = namespaces_table.create!(name: 'group_2', path: 'group_2', type: 'Group') - create_member(user_id: user_2.id, type: 'GroupMember', source_type: 'Namespace', source_id: group_2.id, access_level: described_class::Group::OWNER) - group_import_state_2 = group_import_states_table.create!(group_id: group_2.id, user_id: nil, status: 0) - - disable_migrations_output { migrate! } - - expect(group_import_state_2.reload.user_id).to eq(user_2.id) - end - end - - context 'when group does not contain any owners' do - it 'removes group_import_state record' do - group_3 = namespaces_table.create!(name: 'group_3', path: 'group_3', type: 'Group') - group_import_state_3 = group_import_states_table.create!(group_id: group_3.id, user_id: nil, status: 0) - - disable_migrations_output { migrate! } - - expect { group_import_state_3.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - end - - context 'when group has parent' do - it 'updates user_id with parent group default owner id' do - user = users_table.create!(name: 'user4', email: 'user4@example.com', projects_limit: 1) - group_1 = namespaces_table.create!(name: 'group_1', path: 'group_1', type: 'Group') - create_member(user_id: user.id, type: 'GroupMember', source_type: 'Namespace', source_id: group_1.id, access_level: described_class::Group::OWNER) - group_2 = namespaces_table.create!(name: 'group_2', path: 'group_2', type: 'Group', parent_id: group_1.id) - group_import_state = group_import_states_table.create!(group_id: group_2.id, user_id: nil, status: 0) - - disable_migrations_output { migrate! } - - expect(group_import_state.reload.user_id).to eq(user.id) - end - end - - context 'when group has owner_id' do - it 'updates user_id with owner_id' do - user = users_table.create!(name: 'user', email: 'user@example.com', projects_limit: 1) - group = namespaces_table.create!(name: 'group', path: 'group', type: 'Group', owner_id: user.id) - group_import_state = group_import_states_table.create!(group_id: group.id, user_id: nil, status: 0) - - disable_migrations_output { migrate! } - - expect(group_import_state.reload.user_id).to eq(user.id) - end - end - end - - def create_member(options) - members_table.create!( - { - notification_level: 0, - ldap: false, - override: false - }.merge(options) - ) - end -end diff --git a/spec/migrations/cleanup_move_container_registry_enabled_to_project_feature_spec.rb b/spec/migrations/cleanup_move_container_registry_enabled_to_project_feature_spec.rb new file mode 100644 index 00000000000..f0f9249515b --- /dev/null +++ b/spec/migrations/cleanup_move_container_registry_enabled_to_project_feature_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe CleanupMoveContainerRegistryEnabledToProjectFeature, :migration do + let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') } + let(:non_null_project_features) { { pages_access_level: 20 } } + let(:bg_class_name) { 'MoveContainerRegistryEnabledToProjectFeature' } + + let!(:project1) { table(:projects).create!(namespace_id: namespace.id, name: 'project 1', container_registry_enabled: true) } + let!(:project2) { table(:projects).create!(namespace_id: namespace.id, name: 'project 2', container_registry_enabled: false) } + let!(:project3) { table(:projects).create!(namespace_id: namespace.id, name: 'project 3', container_registry_enabled: nil) } + + let!(:project4) { table(:projects).create!(namespace_id: namespace.id, name: 'project 4', container_registry_enabled: true) } + let!(:project5) { table(:projects).create!(namespace_id: namespace.id, name: 'project 5', container_registry_enabled: false) } + let!(:project6) { table(:projects).create!(namespace_id: namespace.id, name: 'project 6', container_registry_enabled: nil) } + + let!(:project_feature1) { table(:project_features).create!(project_id: project1.id, container_registry_access_level: 20, **non_null_project_features) } + let!(:project_feature2) { table(:project_features).create!(project_id: project2.id, container_registry_access_level: 0, **non_null_project_features) } + let!(:project_feature3) { table(:project_features).create!(project_id: project3.id, container_registry_access_level: 0, **non_null_project_features) } + + let!(:project_feature4) { table(:project_features).create!(project_id: project4.id, container_registry_access_level: 0, **non_null_project_features) } + let!(:project_feature5) { table(:project_features).create!(project_id: project5.id, container_registry_access_level: 20, **non_null_project_features) } + let!(:project_feature6) { table(:project_features).create!(project_id: project6.id, container_registry_access_level: 20, **non_null_project_features) } + + let!(:background_migration_job1) { table(:background_migration_jobs).create!(class_name: bg_class_name, arguments: [project4.id, project5.id], status: 0) } + let!(:background_migration_job2) { table(:background_migration_jobs).create!(class_name: bg_class_name, arguments: [project6.id, project6.id], status: 0) } + let!(:background_migration_job3) { table(:background_migration_jobs).create!(class_name: bg_class_name, arguments: [project1.id, project3.id], status: 1) } + + it 'steals remaining jobs, updates any remaining rows and deletes background_migration_jobs rows' do + expect(Gitlab::BackgroundMigration).to receive(:steal).with(bg_class_name).and_call_original + + migrate! + + expect(project_feature1.reload.container_registry_access_level).to eq(20) + expect(project_feature2.reload.container_registry_access_level).to eq(0) + expect(project_feature3.reload.container_registry_access_level).to eq(0) + expect(project_feature4.reload.container_registry_access_level).to eq(20) + expect(project_feature5.reload.container_registry_access_level).to eq(0) + expect(project_feature6.reload.container_registry_access_level).to eq(0) + + expect(table(:background_migration_jobs).where(class_name: bg_class_name).count).to eq(0) + end +end diff --git a/spec/migrations/cleanup_move_container_registry_enabled_to_project_features_spec.rb b/spec/migrations/cleanup_move_container_registry_enabled_to_project_features_spec.rb deleted file mode 100644 index 3c39327304e..00000000000 --- a/spec/migrations/cleanup_move_container_registry_enabled_to_project_features_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('cleanup_move_container_registry_enabled_to_project_feature') - -RSpec.describe CleanupMoveContainerRegistryEnabledToProjectFeature, :migration do - let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') } - let(:non_null_project_features) { { pages_access_level: 20 } } - let(:bg_class_name) { 'MoveContainerRegistryEnabledToProjectFeature' } - - let!(:project1) { table(:projects).create!(namespace_id: namespace.id, name: 'project 1', container_registry_enabled: true) } - let!(:project2) { table(:projects).create!(namespace_id: namespace.id, name: 'project 2', container_registry_enabled: false) } - let!(:project3) { table(:projects).create!(namespace_id: namespace.id, name: 'project 3', container_registry_enabled: nil) } - - let!(:project4) { table(:projects).create!(namespace_id: namespace.id, name: 'project 4', container_registry_enabled: true) } - let!(:project5) { table(:projects).create!(namespace_id: namespace.id, name: 'project 5', container_registry_enabled: false) } - let!(:project6) { table(:projects).create!(namespace_id: namespace.id, name: 'project 6', container_registry_enabled: nil) } - - let!(:project_feature1) { table(:project_features).create!(project_id: project1.id, container_registry_access_level: 20, **non_null_project_features) } - let!(:project_feature2) { table(:project_features).create!(project_id: project2.id, container_registry_access_level: 0, **non_null_project_features) } - let!(:project_feature3) { table(:project_features).create!(project_id: project3.id, container_registry_access_level: 0, **non_null_project_features) } - - let!(:project_feature4) { table(:project_features).create!(project_id: project4.id, container_registry_access_level: 0, **non_null_project_features) } - let!(:project_feature5) { table(:project_features).create!(project_id: project5.id, container_registry_access_level: 20, **non_null_project_features) } - let!(:project_feature6) { table(:project_features).create!(project_id: project6.id, container_registry_access_level: 20, **non_null_project_features) } - - let!(:background_migration_job1) { table(:background_migration_jobs).create!(class_name: bg_class_name, arguments: [project4.id, project5.id], status: 0) } - let!(:background_migration_job2) { table(:background_migration_jobs).create!(class_name: bg_class_name, arguments: [project6.id, project6.id], status: 0) } - let!(:background_migration_job3) { table(:background_migration_jobs).create!(class_name: bg_class_name, arguments: [project1.id, project3.id], status: 1) } - - it 'steals remaining jobs, updates any remaining rows and deletes background_migration_jobs rows' do - expect(Gitlab::BackgroundMigration).to receive(:steal).with(bg_class_name).and_call_original - - migrate! - - expect(project_feature1.reload.container_registry_access_level).to eq(20) - expect(project_feature2.reload.container_registry_access_level).to eq(0) - expect(project_feature3.reload.container_registry_access_level).to eq(0) - expect(project_feature4.reload.container_registry_access_level).to eq(20) - expect(project_feature5.reload.container_registry_access_level).to eq(0) - expect(project_feature6.reload.container_registry_access_level).to eq(0) - - expect(table(:background_migration_jobs).where(class_name: bg_class_name).count).to eq(0) - end -end diff --git a/spec/migrations/cleanup_optimistic_locking_nulls_pt2_fixed_spec.rb b/spec/migrations/cleanup_optimistic_locking_nulls_pt2_fixed_spec.rb deleted file mode 100644 index 2f461ebc1d5..00000000000 --- a/spec/migrations/cleanup_optimistic_locking_nulls_pt2_fixed_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('cleanup_optimistic_locking_nulls_pt2_fixed') - -RSpec.describe CleanupOptimisticLockingNullsPt2Fixed, :migration, schema: 20200219193117 do - test_tables = %w(ci_stages ci_builds ci_pipelines).freeze - test_tables.each do |table| - let(table.to_sym) { table(table.to_sym) } - end - let(:tables) { test_tables.map { |t| method(t.to_sym).call } } - - before do - # Create necessary rows - ci_stages.create! - ci_builds.create! - ci_pipelines.create! - - # Nullify `lock_version` column for all rows - # Needs to be done with a SQL fragment, otherwise Rails will coerce it to 0 - tables.each do |table| - table.update_all('lock_version = NULL') - end - end - - it 'correctly migrates nullified lock_version column', :sidekiq_might_not_need_inline do - tables.each do |table| - expect(table.where(lock_version: nil).count).to eq(1) - end - - tables.each do |table| - expect(table.where(lock_version: 0).count).to eq(0) - end - - migrate! - - tables.each do |table| - expect(table.where(lock_version: nil).count).to eq(0) - end - - tables.each do |table| - expect(table.where(lock_version: 0).count).to eq(1) - end - end -end diff --git a/spec/migrations/cleanup_optimistic_locking_nulls_spec.rb b/spec/migrations/cleanup_optimistic_locking_nulls_spec.rb deleted file mode 100644 index a287d950c89..00000000000 --- a/spec/migrations/cleanup_optimistic_locking_nulls_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('cleanup_optimistic_locking_nulls') - -RSpec.describe CleanupOptimisticLockingNulls do - let(:epics) { table(:epics) } - let(:merge_requests) { table(:merge_requests) } - let(:issues) { table(:issues) } - let(:tables) { [epics, merge_requests, issues] } - - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:users) { table(:users)} - - before do - namespaces.create!(id: 123, name: 'gitlab1', path: 'gitlab1') - projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1', namespace_id: 123) - users.create!(id: 123, username: 'author', projects_limit: 1000) - - # Create necessary rows - epics.create!(iid: 123, group_id: 123, author_id: 123, title: 'a', title_html: 'a') - merge_requests.create!(iid: 123, target_project_id: 123, source_project_id: 123, target_branch: 'master', source_branch: 'hmm', title: 'a', title_html: 'a') - issues.create!(iid: 123, project_id: 123, title: 'a', title_html: 'a') - - # Nullify `lock_version` column for all rows - # Needs to be done with a SQL fragment, otherwise Rails will coerce it to 0 - tables.each do |table| - table.update_all('lock_version = NULL') - end - end - - it 'correctly migrates nullified lock_version column', :sidekiq_inline do - tables.each do |table| - expect(table.where(lock_version: nil).count).to eq(1) - end - - tables.each do |table| - expect(table.where(lock_version: 0).count).to eq(0) - end - - migrate! - - tables.each do |table| - expect(table.where(lock_version: nil).count).to eq(0) - end - - tables.each do |table| - expect(table.where(lock_version: 0).count).to eq(1) - end - end -end diff --git a/spec/migrations/cleanup_projects_with_missing_namespace_spec.rb b/spec/migrations/cleanup_projects_with_missing_namespace_spec.rb deleted file mode 100644 index c640bfcd174..00000000000 --- a/spec/migrations/cleanup_projects_with_missing_namespace_spec.rb +++ /dev/null @@ -1,142 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration!('add_projects_foreign_key_to_namespaces') -require_migration! - -# In order to test the CleanupProjectsWithMissingNamespace migration, we need -# to first create an orphaned project (one with an invalid namespace_id) -# and then run the migration to check that the project was properly cleaned up -# -# The problem is that the CleanupProjectsWithMissingNamespace migration comes -# after the FK has been added with a previous migration (AddProjectsForeignKeyToNamespaces) -# That means that while testing the current class we can not insert projects with an -# invalid namespace_id as the existing FK is correctly blocking us from doing so -# -# The approach that solves that problem is to: -# - Set the schema of this test to the one prior to AddProjectsForeignKeyToNamespaces -# - We could hardcode it to `20200508091106` (which currently is the previous -# migration before adding the FK) but that would mean that this test depends -# on migration 20200508091106 not being reverted or deleted -# - So, we use SchemaVersionFinder that finds the previous migration and returns -# its schema, which we then use in the describe -# -# That means that we lock the schema version to the one returned by -# SchemaVersionFinder.previous_migration and only test the cleanup migration -# *without* the migration that adds the Foreign Key ever running -# That's acceptable as the cleanup script should not be affected in any way -# by the migration that adds the Foreign Key -class SchemaVersionFinder - def self.migrations_paths - ActiveRecord::Migrator.migrations_paths - end - - def self.migration_context - ActiveRecord::MigrationContext.new(migrations_paths, ActiveRecord::SchemaMigration) - end - - def self.migrations - migration_context.migrations - end - - def self.previous_migration - migrations.each_cons(2) do |previous, migration| - break previous.version if migration.name == AddProjectsForeignKeyToNamespaces.name - end - end -end - -RSpec.describe CleanupProjectsWithMissingNamespace, :migration, schema: SchemaVersionFinder.previous_migration do - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - let(:users) { table(:users) } - - before do - namespace = namespaces.create!(name: 'existing_namespace', path: 'existing_namespace') - - projects.create!( - name: 'project_with_existing_namespace', - path: 'project_with_existing_namespace', - visibility_level: 20, - archived: false, - namespace_id: namespace.id - ) - - projects.create!( - name: 'project_with_non_existing_namespace', - path: 'project_with_non_existing_namespace', - visibility_level: 20, - archived: false, - namespace_id: non_existing_record_id - ) - end - - it 'creates the ghost user' do - expect(users.where(user_type: described_class::User::USER_TYPE_GHOST).count).to eq(0) - - disable_migrations_output { migrate! } - - expect(users.where(user_type: described_class::User::USER_TYPE_GHOST).count).to eq(1) - end - - it 'creates the lost-and-found group, owned by the ghost user' do - expect( - described_class::Group.where( - described_class::Group - .arel_table[:name] - .matches("#{described_class::User::LOST_AND_FOUND_GROUP}%") - ).count - ).to eq(0) - - disable_migrations_output { migrate! } - - ghost_user = users.find_by(user_type: described_class::User::USER_TYPE_GHOST) - expect( - described_class::Group - .joins('INNER JOIN members ON namespaces.id = members.source_id') - .where(namespaces: { type: 'Group' }) - .where(members: { type: 'GroupMember' }) - .where(members: { source_type: 'Namespace' }) - .where(members: { user_id: ghost_user.id }) - .where(members: { requested_at: nil }) - .where(members: { access_level: described_class::ACCESS_LEVEL_OWNER }) - .where( - described_class::Group - .arel_table[:name] - .matches("#{described_class::User::LOST_AND_FOUND_GROUP}%") - ) - .count - ).to eq(1) - end - - it 'moves the orphaned project to the lost-and-found group' do - orphaned_project = projects.find_by(name: 'project_with_non_existing_namespace') - expect(orphaned_project.visibility_level).to eq(20) - expect(orphaned_project.archived).to eq(false) - expect(orphaned_project.namespace_id).to eq(non_existing_record_id) - - disable_migrations_output { migrate! } - - lost_and_found_group = described_class::Group.find_by( - described_class::Group - .arel_table[:name] - .matches("#{described_class::User::LOST_AND_FOUND_GROUP}%") - ) - orphaned_project = projects.find_by(id: orphaned_project.id) - - expect(orphaned_project.visibility_level).to eq(0) - expect(orphaned_project.namespace_id).to eq(lost_and_found_group.id) - expect(orphaned_project.name).to eq("project_with_non_existing_namespace_#{orphaned_project.id}") - expect(orphaned_project.path).to eq("project_with_non_existing_namespace_#{orphaned_project.id}") - expect(orphaned_project.archived).to eq(true) - - valid_project = projects.find_by(name: 'project_with_existing_namespace') - existing_namespace = namespaces.find_by(name: 'existing_namespace') - - expect(valid_project.visibility_level).to eq(20) - expect(valid_project.namespace_id).to eq(existing_namespace.id) - expect(valid_project.path).to eq('project_with_existing_namespace') - expect(valid_project.archived).to eq(false) - end -end diff --git a/spec/migrations/cleanup_remaining_orphan_invites_spec.rb b/spec/migrations/cleanup_remaining_orphan_invites_spec.rb index 0eb1f5a578a..987535a4f09 100644 --- a/spec/migrations/cleanup_remaining_orphan_invites_spec.rb +++ b/spec/migrations/cleanup_remaining_orphan_invites_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration! 'cleanup_remaining_orphan_invites' +require_migration! RSpec.describe CleanupRemainingOrphanInvites, :migration do def create_member(**extra_attributes) diff --git a/spec/migrations/complete_namespace_settings_migration_spec.rb b/spec/migrations/complete_namespace_settings_migration_spec.rb deleted file mode 100644 index 46c455d8b19..00000000000 --- a/spec/migrations/complete_namespace_settings_migration_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe CompleteNamespaceSettingsMigration, :redis do - let(:migration) { spy('migration') } - - context 'when still legacy artifacts exist' do - let(:namespaces) { table(:namespaces) } - let(:namespace_settings) { table(:namespace_settings) } - let!(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - - it 'steals sidekiq jobs from BackfillNamespaceSettings background migration' do - expect(Gitlab::BackgroundMigration).to receive(:steal).with('BackfillNamespaceSettings') - - migrate! - end - - it 'migrates namespaces without namespace_settings' do - expect { migrate! }.to change { namespace_settings.count }.from(0).to(1) - end - end -end diff --git a/spec/migrations/confirm_project_bot_users_spec.rb b/spec/migrations/confirm_project_bot_users_spec.rb deleted file mode 100644 index 5f70181e70a..00000000000 --- a/spec/migrations/confirm_project_bot_users_spec.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ConfirmProjectBotUsers, :migration do - let(:users) { table(:users) } - - context 'project bot users that are currently unconfirmed' do - let!(:project_bot_1) do - create_user!( - name: 'bot_1', - email: 'bot_1@example.com', - created_at: 2.days.ago, - user_type: described_class::User::USER_TYPE_PROJECT_BOT - ) - end - - let!(:project_bot_2) do - create_user!( - name: 'bot_2', - email: 'bot_2@example.com', - created_at: 4.days.ago, - user_type: described_class::User::USER_TYPE_PROJECT_BOT - ) - end - - it 'updates their `confirmed_at` attribute' do - expect { migrate! } - .to change { project_bot_1.reload.confirmed_at } - .and change { project_bot_2.reload.confirmed_at } - end - - it 'sets `confirmed_at` to be the same as their `created_at` attribute' do - migrate! - - [project_bot_1, project_bot_2].each do |bot| - expect(bot.reload.confirmed_at).to eq(bot.created_at) - end - end - end - - context 'project bot users that are currently confirmed' do - let!(:confirmed_project_bot) do - create_user!( - name: 'bot_1', - email: 'bot_1@example.com', - user_type: described_class::User::USER_TYPE_PROJECT_BOT, - confirmed_at: 1.day.ago - ) - end - - it 'does not update their `confirmed_at` attribute' do - expect { migrate! }.not_to change { confirmed_project_bot.reload.confirmed_at } - end - end - - context 'human users that are currently unconfirmed' do - let!(:unconfirmed_human) do - create_user!( - name: 'human', - email: 'human@example.com', - user_type: nil - ) - end - - it 'does not update their `confirmed_at` attribute' do - expect { migrate! }.not_to change { unconfirmed_human.reload.confirmed_at } - end - end - - private - - def create_user!(name:, email:, user_type:, created_at: Time.now, confirmed_at: nil) - users.create!( - name: name, - email: email, - username: name, - projects_limit: 0, - user_type: user_type, - confirmed_at: confirmed_at - ) - end -end diff --git a/spec/migrations/create_environment_for_self_monitoring_project_spec.rb b/spec/migrations/create_environment_for_self_monitoring_project_spec.rb deleted file mode 100644 index 4615c231510..00000000000 --- a/spec/migrations/create_environment_for_self_monitoring_project_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe CreateEnvironmentForSelfMonitoringProject do - let(:application_settings_table) { table(:application_settings) } - - let(:environments) { table(:environments) } - - let(:instance_administrators_group) do - table(:namespaces).create!( - id: 1, - name: 'GitLab Instance Administrators', - path: 'gitlab-instance-administrators-random', - type: 'Group' - ) - end - - let(:self_monitoring_project) do - table(:projects).create!( - id: 2, - name: 'Self Monitoring', - path: 'self_monitoring', - namespace_id: instance_administrators_group.id - ) - end - - context 'when the self monitoring project ID is not set' do - it 'does not make changes' do - expect(environments.find_by(project_id: self_monitoring_project.id)).to be_nil - - migrate! - - expect(environments.find_by(project_id: self_monitoring_project.id)).to be_nil - end - end - - context 'when the self monitoring project ID is set' do - before do - application_settings_table.create!(instance_administration_project_id: self_monitoring_project.id) - end - - context 'when the environment already exists' do - let!(:environment) do - environments.create!(project_id: self_monitoring_project.id, name: 'production', slug: 'production') - end - - it 'does not make changes' do - expect(environments.find_by(project_id: self_monitoring_project.id)).to eq(environment) - - migrate! - - expect(environments.find_by(project_id: self_monitoring_project.id)).to eq(environment) - end - end - - context 'when the environment does not exist' do - it 'creates the environment' do - expect(environments.find_by(project_id: self_monitoring_project.id)).to be_nil - - migrate! - - expect(environments.find_by(project_id: self_monitoring_project.id)).to be - end - end - end -end diff --git a/spec/migrations/deduplicate_epic_iids_spec.rb b/spec/migrations/deduplicate_epic_iids_spec.rb deleted file mode 100644 index c9dd5b3253b..00000000000 --- a/spec/migrations/deduplicate_epic_iids_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DeduplicateEpicIids, :migration, schema: 20201106082723 do - let(:routes) { table(:routes) } - let(:epics) { table(:epics) } - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - - let!(:group) { create_group('foo') } - let!(:user) { users.create!(email: 'test@example.com', projects_limit: 100, username: 'test') } - let!(:dup_epic1) { epics.create!(iid: 1, title: 'epic 1', group_id: group.id, author_id: user.id, created_at: Time.now, updated_at: Time.now, title_html: 'any') } - let!(:dup_epic2) { epics.create!(iid: 1, title: 'epic 2', group_id: group.id, author_id: user.id, created_at: Time.now, updated_at: Time.now, title_html: 'any') } - let!(:dup_epic3) { epics.create!(iid: 1, title: 'epic 3', group_id: group.id, author_id: user.id, created_at: Time.now, updated_at: Time.now, title_html: 'any') } - - it 'deduplicates epic iids', :aggregate_failures do - duplicate_epics_count = epics.where(iid: 1, group_id: group.id).count - expect(duplicate_epics_count).to eq 3 - - migrate! - - duplicate_epics_count = epics.where(iid: 1, group_id: group.id).count - expect(duplicate_epics_count).to eq 1 - expect(dup_epic1.reload.iid).to eq 1 - expect(dup_epic2.reload.iid).to eq 2 - expect(dup_epic3.reload.iid).to eq 3 - end - - def create_group(path) - namespaces.create!(name: path, path: path, type: 'Group').tap do |namespace| - routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace') - end - end -end diff --git a/spec/migrations/delete_internal_ids_where_feature_flags_usage_spec.rb b/spec/migrations/delete_internal_ids_where_feature_flags_usage_spec.rb deleted file mode 100644 index 30d776c498b..00000000000 --- a/spec/migrations/delete_internal_ids_where_feature_flags_usage_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DeleteInternalIdsWhereFeatureFlagsUsage do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:internal_ids) { table(:internal_ids) } - - def setup - namespace = namespaces.create!(name: 'foo', path: 'foo') - projects.create!(namespace_id: namespace.id) - end - - it 'deletes feature flag rows from the internal_ids table' do - project = setup - internal_ids.create!(project_id: project.id, usage: 6, last_value: 1) - - disable_migrations_output { migrate! } - - expect(internal_ids.count).to eq(0) - end - - it 'does not delete issue rows from the internal_ids table' do - project = setup - internal_ids.create!(project_id: project.id, usage: 0, last_value: 1) - - disable_migrations_output { migrate! } - - expect(internal_ids.count).to eq(1) - end - - it 'does not delete merge request rows from the internal_ids table' do - project = setup - internal_ids.create!(project_id: project.id, usage: 1, last_value: 1) - - disable_migrations_output { migrate! } - - expect(internal_ids.count).to eq(1) - end -end diff --git a/spec/migrations/delete_template_project_services_spec.rb b/spec/migrations/delete_template_project_services_spec.rb deleted file mode 100644 index 20532e4187a..00000000000 --- a/spec/migrations/delete_template_project_services_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DeleteTemplateProjectServices, :migration do - let(:services) { table(:services) } - let(:project) { table(:projects).create!(namespace_id: 1) } - - before do - services.create!(template: true, project_id: project.id) - services.create!(template: true) - services.create!(template: false, project_id: project.id) - end - - it 'deletes services when template and attached to a project' do - expect { migrate! }.to change { services.where(template: true, project_id: project.id).count }.from(1).to(0) - .and not_change { services.where(template: true, project_id: nil).count } - .and not_change { services.where(template: false).where.not(project_id: nil).count } - end -end diff --git a/spec/migrations/delete_template_services_duplicated_by_type_spec.rb b/spec/migrations/delete_template_services_duplicated_by_type_spec.rb deleted file mode 100644 index 577fea984da..00000000000 --- a/spec/migrations/delete_template_services_duplicated_by_type_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DeleteTemplateServicesDuplicatedByType do - let(:services) { table(:services) } - - before do - services.create!(template: true, type: 'JenkinsService') - services.create!(template: true, type: 'JenkinsService') - services.create!(template: true, type: 'JiraService') - services.create!(template: true, type: 'JenkinsService') - end - - it 'deletes service templates duplicated by type except the one with the lowest ID' do - jenkins_integration_id = services.where(type: 'JenkinsService').order(:id).pluck(:id).first - jira_integration_id = services.where(type: 'JiraService').pluck(:id).first - - migrate! - - expect(services.pluck(:id)).to contain_exactly(jenkins_integration_id, jira_integration_id) - end -end diff --git a/spec/migrations/delete_user_callout_alerts_moved_spec.rb b/spec/migrations/delete_user_callout_alerts_moved_spec.rb deleted file mode 100644 index 401cf77628d..00000000000 --- a/spec/migrations/delete_user_callout_alerts_moved_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DeleteUserCalloutAlertsMoved do - let(:users) { table(:users) } - let(:user_callouts) { table(:user_callouts) } - let(:alerts_moved_feature) { described_class::FEATURE_NAME_ALERTS_MOVED } - let(:unrelated_feature) { 1 } - - let!(:user1) { users.create!(email: '1', projects_limit: 0) } - let!(:user2) { users.create!(email: '2', projects_limit: 0) } - - subject(:migration) { described_class.new } - - before do - user_callouts.create!(user_id: user1.id, feature_name: alerts_moved_feature) - user_callouts.create!(user_id: user1.id, feature_name: unrelated_feature) - user_callouts.create!(user_id: user2.id, feature_name: alerts_moved_feature) - end - - describe '#up' do - it 'deletes `alerts_moved` user callouts' do - migration.up - - expect(user_callouts.all.map(&:feature_name)).to eq([unrelated_feature]) - end - end -end diff --git a/spec/migrations/drop_activate_prometheus_services_background_jobs_spec.rb b/spec/migrations/drop_activate_prometheus_services_background_jobs_spec.rb deleted file mode 100644 index c6115d5889c..00000000000 --- a/spec/migrations/drop_activate_prometheus_services_background_jobs_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DropActivatePrometheusServicesBackgroundJobs, :sidekiq, :redis, schema: 2020_02_21_144534 do - subject(:migration) { described_class.new } - - describe '#up' do - let(:retry_set) { Sidekiq::RetrySet.new } - let(:scheduled_set) { Sidekiq::ScheduledSet.new } - - context 'there are only affected jobs on the queue' do - let(:payload) { { 'class' => ::BackgroundMigrationWorker, 'args' => [described_class::DROPPED_JOB_CLASS, 1] } } - let(:queue_payload) { payload.merge('queue' => described_class::QUEUE) } - - it 'removes enqueued ActivatePrometheusServicesForSharedClusterApplications background jobs' do - Sidekiq::Testing.disable! do # https://github.com/mperham/sidekiq/wiki/testing#api Sidekiq's API does not have a testing mode - retry_set.schedule(1.hour.from_now, payload) - scheduled_set.schedule(1.hour.from_now, payload) - Sidekiq::Client.push(queue_payload) - - expect { migration.up }.to change { Sidekiq::Queue.new(described_class::QUEUE).size }.from(1).to(0) - expect(retry_set.size).to eq(0) - expect(scheduled_set.size).to eq(0) - end - end - end - - context "there aren't any affected jobs on the queue" do - let(:payload) { { 'class' => ::BackgroundMigrationWorker, 'args' => ['SomeOtherClass', 1] } } - let(:queue_payload) { payload.merge('queue' => described_class::QUEUE) } - - it 'skips other enqueued jobs' do - Sidekiq::Testing.disable! do - retry_set.schedule(1.hour.from_now, payload) - scheduled_set.schedule(1.hour.from_now, payload) - Sidekiq::Client.push(queue_payload) - - expect { migration.up }.not_to change { Sidekiq::Queue.new(described_class::QUEUE).size } - expect(retry_set.size).to eq(1) - expect(scheduled_set.size).to eq(1) - end - end - end - - context "there are multiple types of jobs on the queue" do - let(:payload) { { 'class' => ::BackgroundMigrationWorker, 'args' => [described_class::DROPPED_JOB_CLASS, 1] } } - let(:queue_payload) { payload.merge('queue' => described_class::QUEUE) } - - it 'skips other enqueued jobs' do - Sidekiq::Testing.disable! do - queue = Sidekiq::Queue.new(described_class::QUEUE) - # these jobs will be deleted - retry_set.schedule(1.hour.from_now, payload) - scheduled_set.schedule(1.hour.from_now, payload) - Sidekiq::Client.push(queue_payload) - # this jobs will be skipped - skipped_jobs_args = [['SomeOtherClass', 1], [described_class::DROPPED_JOB_CLASS, 'wrong id type'], [described_class::DROPPED_JOB_CLASS, 1, 'some wired argument']] - skipped_jobs_args.each do |args| - retry_set.schedule(1.hour.from_now, { 'class' => ::BackgroundMigrationWorker, 'args' => args }) - scheduled_set.schedule(1.hour.from_now, { 'class' => ::BackgroundMigrationWorker, 'args' => args }) - Sidekiq::Client.push('queue' => described_class::QUEUE, 'class' => ::BackgroundMigrationWorker, 'args' => args) - end - - migration.up - - expect(retry_set.size).to be 3 - expect(scheduled_set.size).to be 3 - expect(queue.size).to be 3 - expect(queue.map(&:args)).to match_array skipped_jobs_args - expect(retry_set.map(&:args)).to match_array skipped_jobs_args - expect(scheduled_set.map(&:args)).to match_array skipped_jobs_args - end - end - end - - context "other queues" do - it 'does not modify them' do - Sidekiq::Testing.disable! do - Sidekiq::Client.push('queue' => 'other', 'class' => ::BackgroundMigrationWorker, 'args' => ['SomeOtherClass', 1]) - Sidekiq::Client.push('queue' => 'other', 'class' => ::BackgroundMigrationWorker, 'args' => [described_class::DROPPED_JOB_CLASS, 1]) - - expect { migration.up }.not_to change { Sidekiq::Queue.new('other').size } - end - end - end - end -end diff --git a/spec/migrations/drop_background_migration_jobs_spec.rb b/spec/migrations/drop_background_migration_jobs_spec.rb deleted file mode 100644 index 82b3f9f7187..00000000000 --- a/spec/migrations/drop_background_migration_jobs_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DropBackgroundMigrationJobs, :sidekiq, :redis, schema: 2020_01_16_051619 do - subject(:migration) { described_class.new } - - describe '#up' do - context 'there are only affected jobs on the queue' do - it 'removes enqueued ActivatePrometheusServicesForSharedClusterApplications background jobs' do - Sidekiq::Testing.disable! do # https://github.com/mperham/sidekiq/wiki/testing#api Sidekiq's API does not have a testing mode - Sidekiq::Client.push('queue' => described_class::QUEUE, 'class' => ::BackgroundMigrationWorker, 'args' => [described_class::DROPPED_JOB_CLASS, 1]) - - expect { migration.up }.to change { Sidekiq::Queue.new(described_class::QUEUE).size }.from(1).to(0) - end - end - end - - context "there aren't any affected jobs on the queue" do - it 'skips other enqueued jobs' do - Sidekiq::Testing.disable! do - Sidekiq::Client.push('queue' => described_class::QUEUE, 'class' => ::BackgroundMigrationWorker, 'args' => ['SomeOtherClass', 1]) - - expect { migration.up }.not_to change { Sidekiq::Queue.new(described_class::QUEUE).size } - end - end - end - - context "there are multiple types of jobs on the queue" do - it 'skips other enqueued jobs' do - Sidekiq::Testing.disable! do - queue = Sidekiq::Queue.new(described_class::QUEUE) - # this job will be deleted - Sidekiq::Client.push('queue' => described_class::QUEUE, 'class' => ::BackgroundMigrationWorker, 'args' => [described_class::DROPPED_JOB_CLASS, 1]) - # this jobs will be skipped - skipped_jobs_args = [['SomeOtherClass', 1], [described_class::DROPPED_JOB_CLASS, 'wrong id type'], [described_class::DROPPED_JOB_CLASS, 1, 'some wired argument']] - skipped_jobs_args.each do |args| - Sidekiq::Client.push('queue' => described_class::QUEUE, 'class' => ::BackgroundMigrationWorker, 'args' => args) - end - - migration.up - - expect(queue.size).to be 3 - expect(queue.map(&:args)).to match_array skipped_jobs_args - end - end - end - - context "other queues" do - it 'does not modify them' do - Sidekiq::Testing.disable! do - Sidekiq::Client.push('queue' => 'other', 'class' => ::BackgroundMigrationWorker, 'args' => ['SomeOtherClass', 1]) - Sidekiq::Client.push('queue' => 'other', 'class' => ::BackgroundMigrationWorker, 'args' => [described_class::DROPPED_JOB_CLASS, 1]) - - expect { migration.up }.not_to change { Sidekiq::Queue.new('other').size } - end - end - end - end -end diff --git a/spec/migrations/ensure_filled_external_diff_store_on_merge_request_diffs_spec.rb b/spec/migrations/ensure_filled_external_diff_store_on_merge_request_diffs_spec.rb deleted file mode 100644 index 6998e7a91cf..00000000000 --- a/spec/migrations/ensure_filled_external_diff_store_on_merge_request_diffs_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EnsureFilledExternalDiffStoreOnMergeRequestDiffs, schema: 20200908095446 do - let!(:merge_request_diffs) { table(:merge_request_diffs) } - let!(:merge_requests) { table(:merge_requests) } - let!(:namespaces) { table(:namespaces) } - let!(:projects) { table(:projects) } - let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(namespace_id: namespace.id) } - let!(:merge_request) { merge_requests.create!(source_branch: 'x', target_branch: 'master', target_project_id: project.id) } - - before do - constraint_name = 'check_93ee616ac9' - - # In order to insert a row with a NULL to fill. - ActiveRecord::Base.connection.execute "ALTER TABLE merge_request_diffs DROP CONSTRAINT #{constraint_name}" - - @external_diff_store_1 = merge_request_diffs.create!(external_diff_store: 1, merge_request_id: merge_request.id) - @external_diff_store_2 = merge_request_diffs.create!(external_diff_store: 2, merge_request_id: merge_request.id) - @external_diff_store_nil = merge_request_diffs.create!(external_diff_store: nil, merge_request_id: merge_request.id) - - # revert DB structure - ActiveRecord::Base.connection.execute "ALTER TABLE merge_request_diffs ADD CONSTRAINT #{constraint_name} CHECK ((external_diff_store IS NOT NULL)) NOT VALID" - end - - it 'correctly migrates nil external_diff_store to 1' do - migrate! - - @external_diff_store_1.reload - @external_diff_store_2.reload - @external_diff_store_nil.reload - - expect(@external_diff_store_1.external_diff_store).to eq(1) # unchanged - expect(@external_diff_store_2.external_diff_store).to eq(2) # unchanged - expect(@external_diff_store_nil.external_diff_store).to eq(1) # nil => 1 - end -end diff --git a/spec/migrations/ensure_filled_file_store_on_package_files_spec.rb b/spec/migrations/ensure_filled_file_store_on_package_files_spec.rb deleted file mode 100644 index 5cfc3a6eeb8..00000000000 --- a/spec/migrations/ensure_filled_file_store_on_package_files_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EnsureFilledFileStoreOnPackageFiles, schema: 20200910175553 do - let!(:packages_package_files) { table(:packages_package_files) } - let!(:packages_packages) { table(:packages_packages) } - let!(:namespaces) { table(:namespaces) } - let!(:projects) { table(:projects) } - let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(namespace_id: namespace.id) } - let!(:package) { packages_packages.create!(project_id: project.id, name: 'bar', package_type: 1) } - - before do - constraint_name = 'check_4c5e6bb0b3' - - # In order to insert a row with a NULL to fill. - ActiveRecord::Base.connection.execute "ALTER TABLE packages_package_files DROP CONSTRAINT #{constraint_name}" - - @file_store_1 = packages_package_files.create!(file_store: 1, file_name: 'foo_1', file: 'foo_1', package_id: package.id) - @file_store_2 = packages_package_files.create!(file_store: 2, file_name: 'foo_2', file: 'foo_2', package_id: package.id) - @file_store_nil = packages_package_files.create!(file_store: nil, file_name: 'foo_nil', file: 'foo_nil', package_id: package.id) - - # revert DB structure - ActiveRecord::Base.connection.execute "ALTER TABLE packages_package_files ADD CONSTRAINT #{constraint_name} CHECK ((file_store IS NOT NULL)) NOT VALID" - end - - it 'correctly migrates nil file_store to 1' do - migrate! - - @file_store_1.reload - @file_store_2.reload - @file_store_nil.reload - - expect(@file_store_1.file_store).to eq(1) # unchanged - expect(@file_store_2.file_store).to eq(2) # unchanged - expect(@file_store_nil.file_store).to eq(1) # nil => 1 - end -end diff --git a/spec/migrations/ensure_namespace_settings_creation_spec.rb b/spec/migrations/ensure_namespace_settings_creation_spec.rb deleted file mode 100644 index b105e678d35..00000000000 --- a/spec/migrations/ensure_namespace_settings_creation_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EnsureNamespaceSettingsCreation do - context 'when there are namespaces without namespace settings' do - let(:namespaces) { table(:namespaces) } - let(:namespace_settings) { table(:namespace_settings) } - let!(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let!(:namespace_2) { namespaces.create!(name: 'gitlab', path: 'gitlab-org2') } - - it 'migrates namespaces without namespace_settings' do - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes.to_i, namespace.id, namespace_2.id) - end - end - end - - it 'schedules migrations in batches' do - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - namespace_3 = namespaces.create!(name: 'gitlab', path: 'gitlab-org3') - namespace_4 = namespaces.create!(name: 'gitlab', path: 'gitlab-org4') - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes.to_i, namespace.id, namespace_2.id) - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(4.minutes.to_i, namespace_3.id, namespace_4.id) - end - end - end - end -end diff --git a/spec/migrations/ensure_target_project_id_is_filled_spec.rb b/spec/migrations/ensure_target_project_id_is_filled_spec.rb deleted file mode 100644 index 7a9f49390fb..00000000000 --- a/spec/migrations/ensure_target_project_id_is_filled_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EnsureTargetProjectIdIsFilled, schema: 20200827085101 do - let_it_be(:namespaces) { table(:namespaces) } - let_it_be(:projects) { table(:projects) } - let_it_be(:merge_requests) { table(:merge_requests) } - let_it_be(:metrics) { table(:merge_request_metrics) } - - let!(:namespace) { namespaces.create!(name: 'namespace', path: 'namespace') } - let!(:project_1) { projects.create!(namespace_id: namespace.id) } - let!(:project_2) { projects.create!(namespace_id: namespace.id) } - let!(:merge_request_to_migrate_1) { merge_requests.create!(source_branch: 'a', target_branch: 'b', target_project_id: project_1.id) } - let!(:merge_request_to_migrate_2) { merge_requests.create!(source_branch: 'c', target_branch: 'd', target_project_id: project_2.id) } - let!(:merge_request_not_to_migrate) { merge_requests.create!(source_branch: 'e', target_branch: 'f', target_project_id: project_1.id) } - - let!(:metrics_1) { metrics.create!(merge_request_id: merge_request_to_migrate_1.id) } - let!(:metrics_2) { metrics.create!(merge_request_id: merge_request_to_migrate_2.id) } - let!(:metrics_3) { metrics.create!(merge_request_id: merge_request_not_to_migrate.id, target_project_id: project_1.id) } - - it 'migrates missing target_project_ids' do - migrate! - - expect(metrics_1.reload.target_project_id).to eq(project_1.id) - expect(metrics_2.reload.target_project_id).to eq(project_2.id) - expect(metrics_3.reload.target_project_id).to eq(project_1.id) - end -end diff --git a/spec/migrations/ensure_u2f_registrations_migrated_spec.rb b/spec/migrations/ensure_u2f_registrations_migrated_spec.rb deleted file mode 100644 index 01db29c0edf..00000000000 --- a/spec/migrations/ensure_u2f_registrations_migrated_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EnsureU2fRegistrationsMigrated, schema: 20201022144501 do - let(:u2f_registrations) { table(:u2f_registrations) } - let(:webauthn_registrations) { table(:webauthn_registrations) } - let(:users) { table(:users) } - - let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) } - - before do - create_u2f_registration(1, 'reg1') - create_u2f_registration(2, 'reg2') - webauthn_registrations.create!({ name: 'reg1', u2f_registration_id: 1, credential_xid: '', public_key: '', user_id: user.id }) - end - - it 'correctly migrates u2f registrations previously not migrated' do - expect { migrate! }.to change { webauthn_registrations.count }.from(1).to(2) - end - - it 'migrates all valid u2f registrations depite errors' do - create_u2f_registration(3, 'reg3', 'invalid!') - create_u2f_registration(4, 'reg4') - - expect { migrate! }.to change { webauthn_registrations.count }.from(1).to(3) - end - - def create_u2f_registration(id, name, public_key = nil) - device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5), { key_handle: SecureRandom.random_bytes(255) }) - public_key ||= Base64.strict_encode64(device.origin_public_key_raw) - u2f_registrations.create!({ id: id, - certificate: Base64.strict_encode64(device.cert_raw), - key_handle: U2F.urlsafe_encode64(device.key_handle_raw), - public_key: public_key, - counter: 5, - name: name, - user_id: user.id }) - end -end diff --git a/spec/migrations/fill_file_store_ci_job_artifacts_spec.rb b/spec/migrations/fill_file_store_ci_job_artifacts_spec.rb deleted file mode 100644 index 7adcf74bdba..00000000000 --- a/spec/migrations/fill_file_store_ci_job_artifacts_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FillFileStoreCiJobArtifacts do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:builds) { table(:ci_builds) } - let(:job_artifacts) { table(:ci_job_artifacts) } - - before do - namespaces.create!(id: 123, name: 'sample', path: 'sample') - projects.create!(id: 123, name: 'sample', path: 'sample', namespace_id: 123) - builds.create!(id: 1) - end - - context 'when file_store is nil' do - it 'updates file_store to local' do - job_artifacts.create!(project_id: 123, job_id: 1, file_type: 1, file_store: nil) - job_artifact = job_artifacts.find_by(project_id: 123, job_id: 1) - - expect { migrate! }.to change { job_artifact.reload.file_store }.from(nil).to(1) - end - end - - context 'when file_store is set to local' do - it 'does not update file_store' do - job_artifacts.create!(project_id: 123, job_id: 1, file_type: 1, file_store: 1) - job_artifact = job_artifacts.find_by(project_id: 123, job_id: 1) - - expect { migrate! }.not_to change { job_artifact.reload.file_store } - end - end - - context 'when file_store is set to object storage' do - it 'does not update file_store' do - job_artifacts.create!(project_id: 123, job_id: 1, file_type: 1, file_store: 2) - job_artifact = job_artifacts.find_by(project_id: 123, job_id: 1) - - expect { migrate! }.not_to change { job_artifact.reload.file_store } - end - end -end diff --git a/spec/migrations/fill_file_store_lfs_objects_spec.rb b/spec/migrations/fill_file_store_lfs_objects_spec.rb deleted file mode 100644 index 688976f79e8..00000000000 --- a/spec/migrations/fill_file_store_lfs_objects_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FillFileStoreLfsObjects do - let(:lfs_objects) { table(:lfs_objects) } - let(:oid) { 'b804383982bb89b00e828e3f44c038cc991d3d1768009fc39ba8e2c081b9fb75' } - - context 'when file_store is nil' do - it 'updates file_store to local' do - lfs_objects.create!(oid: oid, size: 1062, file_store: nil) - lfs_object = lfs_objects.find_by(oid: oid) - - expect { migrate! }.to change { lfs_object.reload.file_store }.from(nil).to(1) - end - end - - context 'when file_store is set to local' do - it 'does not update file_store' do - lfs_objects.create!(oid: oid, size: 1062, file_store: 1) - lfs_object = lfs_objects.find_by(oid: oid) - - expect { migrate! }.not_to change { lfs_object.reload.file_store } - end - end - - context 'when file_store is set to object storage' do - it 'does not update file_store' do - lfs_objects.create!(oid: oid, size: 1062, file_store: 2) - lfs_object = lfs_objects.find_by(oid: oid) - - expect { migrate! }.not_to change { lfs_object.reload.file_store } - end - end -end diff --git a/spec/migrations/fill_store_uploads_spec.rb b/spec/migrations/fill_store_uploads_spec.rb deleted file mode 100644 index 19db7c2b48d..00000000000 --- a/spec/migrations/fill_store_uploads_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FillStoreUploads do - let(:uploads) { table(:uploads) } - let(:path) { 'uploads/-/system/avatar.jpg' } - - context 'when store is nil' do - it 'updates store to local' do - uploads.create!(size: 100.kilobytes, - uploader: 'AvatarUploader', - path: path, - store: nil) - - upload = uploads.find_by(path: path) - - expect { migrate! }.to change { upload.reload.store }.from(nil).to(1) - end - end - - context 'when store is set to local' do - it 'does not update store' do - uploads.create!(size: 100.kilobytes, - uploader: 'AvatarUploader', - path: path, - store: 1) - - upload = uploads.find_by(path: path) - - expect { migrate! }.not_to change { upload.reload.store } - end - end - - context 'when store is set to object storage' do - it 'does not update store' do - uploads.create!(size: 100.kilobytes, - uploader: 'AvatarUploader', - path: path, - store: 2) - - upload = uploads.find_by(path: path) - - expect { migrate! }.not_to change { upload.reload.store } - end - end -end diff --git a/spec/migrations/fix_projects_without_project_feature_spec.rb b/spec/migrations/fix_projects_without_project_feature_spec.rb deleted file mode 100644 index d8c5e7a28c0..00000000000 --- a/spec/migrations/fix_projects_without_project_feature_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FixProjectsWithoutProjectFeature do - let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') } - - let!(:projects) do - [ - table(:projects).create!(namespace_id: namespace.id, name: 'foo 1'), - table(:projects).create!(namespace_id: namespace.id, name: 'foo 2'), - table(:projects).create!(namespace_id: namespace.id, name: 'foo 3') - ] - end - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 2) - end - - around do |example| - Sidekiq::Testing.fake! do - freeze_time do - example.call - end - end - end - - it 'schedules jobs for ranges of projects' do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes, projects[0].id, projects[1].id) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(4.minutes, projects[2].id, projects[2].id) - end - - it 'schedules jobs according to the configured batch size' do - expect { migrate! }.to change { BackgroundMigrationWorker.jobs.size }.by(2) - end -end diff --git a/spec/migrations/fix_projects_without_prometheus_services_spec.rb b/spec/migrations/fix_projects_without_prometheus_services_spec.rb deleted file mode 100644 index dc03f381abd..00000000000 --- a/spec/migrations/fix_projects_without_prometheus_services_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true -# -require 'spec_helper' -require_migration!('fix_projects_without_prometheus_service') - -RSpec.describe FixProjectsWithoutPrometheusService, :migration do - let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') } - - let!(:projects) do - [ - table(:projects).create!(namespace_id: namespace.id, name: 'foo 1'), - table(:projects).create!(namespace_id: namespace.id, name: 'foo 2'), - table(:projects).create!(namespace_id: namespace.id, name: 'foo 3') - ] - end - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 2) - end - - around do |example| - Sidekiq::Testing.fake! do - freeze_time do - example.call - end - end - end - - it 'schedules jobs for ranges of projects' do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes, projects[0].id, projects[1].id) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(4.minutes, projects[2].id, projects[2].id) - end - - it 'schedules jobs according to the configured batch size' do - expect { migrate! }.to change { BackgroundMigrationWorker.jobs.size }.by(2) - end -end diff --git a/spec/migrations/generate_ci_jwt_signing_key_spec.rb b/spec/migrations/generate_ci_jwt_signing_key_spec.rb deleted file mode 100644 index 7a895284aa1..00000000000 --- a/spec/migrations/generate_ci_jwt_signing_key_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe GenerateCiJwtSigningKey do - let(:application_settings) do - Class.new(ActiveRecord::Base) do - self.table_name = 'application_settings' - - attr_encrypted :ci_jwt_signing_key, { - mode: :per_attribute_iv, - key: Gitlab::Utils.ensure_utf8_size(Rails.application.secrets.db_key_base, bytes: 32.bytes), - algorithm: 'aes-256-gcm', - encode: true - } - end - end - - it 'generates JWT signing key' do - application_settings.create! - - reversible_migration do |migration| - migration.before -> { - settings = application_settings.first - - expect(settings.ci_jwt_signing_key).to be_nil - expect(settings.encrypted_ci_jwt_signing_key).to be_nil - expect(settings.encrypted_ci_jwt_signing_key_iv).to be_nil - } - - migration.after -> { - settings = application_settings.first - - expect(settings.encrypted_ci_jwt_signing_key).to be_present - expect(settings.encrypted_ci_jwt_signing_key_iv).to be_present - expect { OpenSSL::PKey::RSA.new(settings.ci_jwt_signing_key) }.not_to raise_error - } - end - end -end diff --git a/spec/migrations/generate_missing_routes_for_bots_spec.rb b/spec/migrations/generate_missing_routes_for_bots_spec.rb deleted file mode 100644 index 594e51b4410..00000000000 --- a/spec/migrations/generate_missing_routes_for_bots_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe GenerateMissingRoutesForBots, :migration do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:routes) { table(:routes) } - - let(:visual_review_bot) do - users.create!(email: 'visual-review-bot@gitlab.com', name: 'GitLab Visual Review Bot', username: 'visual-review-bot', user_type: 3, projects_limit: 5) - end - - let(:migration_bot) do - users.create!(email: 'migration-bot@gitlab.com', name: 'GitLab Migration Bot', username: 'migration-bot', user_type: 7, projects_limit: 5) - end - - let!(:visual_review_bot_namespace) do - namespaces.create!(owner_id: visual_review_bot.id, name: visual_review_bot.name, path: visual_review_bot.username) - end - - let!(:migration_bot_namespace) do - namespaces.create!(owner_id: migration_bot.id, name: migration_bot.name, path: migration_bot.username) - end - - context 'for bot users without an existing route' do - it 'creates new routes' do - expect { migrate! }.to change { routes.count }.by(2) - end - - it 'creates new routes with the same path and name as their namespace' do - migrate! - - [visual_review_bot, migration_bot].each do |bot| - namespace = namespaces.find_by(owner_id: bot.id) - route = route_for(namespace: namespace) - - expect(route.path).to eq(namespace.path) - expect(route.name).to eq(namespace.name) - end - end - end - - it 'does not create routes for bot users with existing routes' do - create_route!(namespace: visual_review_bot_namespace) - create_route!(namespace: migration_bot_namespace) - - expect { migrate! }.not_to change { routes.count } - end - - it 'does not create routes for human users without an existing route' do - human_namespace = create_human_namespace!(name: 'GitLab Human', username: 'human') - - expect { migrate! }.not_to change { route_for(namespace: human_namespace) } - end - - it 'does not create route for a bot user with a missing route, if a human user with the same path already exists' do - human_namespace = create_human_namespace!(name: visual_review_bot.name, username: visual_review_bot.username) - create_route!(namespace: human_namespace) - - expect { migrate! }.not_to change { route_for(namespace: visual_review_bot_namespace) } - end - - private - - def create_human_namespace!(name:, username:) - human = users.create!(email: 'human@gitlab.com', name: name, username: username, user_type: nil, projects_limit: 5) - namespaces.create!(owner_id: human.id, name: human.name, path: human.username) - end - - def create_route!(namespace:) - routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace') - end - - def route_for(namespace:) - routes.find_by(source_type: 'Namespace', source_id: namespace.id) - end -end diff --git a/spec/migrations/insert_daily_invites_plan_limits_spec.rb b/spec/migrations/insert_daily_invites_plan_limits_spec.rb deleted file mode 100644 index 49d41a1039f..00000000000 --- a/spec/migrations/insert_daily_invites_plan_limits_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe InsertDailyInvitesPlanLimits do - let(:plans) { table(:plans) } - let(:plan_limits) { table(:plan_limits) } - let!(:free_plan) { plans.create!(name: 'free') } - let!(:bronze_plan) { plans.create!(name: 'bronze') } - let!(:silver_plan) { plans.create!(name: 'silver') } - let!(:gold_plan) { plans.create!(name: 'gold') } - - context 'when on Gitlab.com' do - before do - expect(Gitlab).to receive(:com?).at_most(:twice).and_return(true) - end - - it 'correctly migrates up and down' do - reversible_migration do |migration| - migration.before -> { - expect(plan_limits.where.not(daily_invites: 0)).to be_empty - } - - # Expectations will run after the up migration. - migration.after -> { - expect(plan_limits.pluck(:plan_id, :daily_invites)).to contain_exactly( - [free_plan.id, 20], - [bronze_plan.id, 0], - [silver_plan.id, 0], - [gold_plan.id, 0] - ) - } - end - end - end - - context 'when on self hosted' do - before do - expect(Gitlab).to receive(:com?).at_most(:twice).and_return(false) - end - - it 'correctly migrates up and down' do - reversible_migration do |migration| - migration.before -> { - expect(plan_limits.pluck(:daily_invites)).to eq [] - } - - migration.after -> { - expect(plan_limits.pluck(:daily_invites)).to eq [] - } - end - end - end -end diff --git a/spec/migrations/insert_project_feature_flags_plan_limits_spec.rb b/spec/migrations/insert_project_feature_flags_plan_limits_spec.rb deleted file mode 100644 index 481e987c188..00000000000 --- a/spec/migrations/insert_project_feature_flags_plan_limits_spec.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe InsertProjectFeatureFlagsPlanLimits do - let(:migration) { described_class.new } - let(:plans) { table(:plans) } - let(:plan_limits) { table(:plan_limits) } - let!(:default_plan) { plans.create!(name: 'default') } - let!(:free_plan) { plans.create!(name: 'free') } - let!(:bronze_plan) { plans.create!(name: 'bronze') } - let!(:silver_plan) { plans.create!(name: 'silver') } - let!(:gold_plan) { plans.create!(name: 'gold') } - let!(:default_plan_limits) do - plan_limits.create!(plan_id: default_plan.id, project_feature_flags: 200) - end - - context 'when on Gitlab.com' do - before do - expect(Gitlab).to receive(:com?).at_most(:twice).and_return(true) - end - - describe '#up' do - it 'updates the project_feature_flags plan limits' do - migration.up - - expect(plan_limits.pluck(:plan_id, :project_feature_flags)).to contain_exactly( - [default_plan.id, 200], - [free_plan.id, 50], - [bronze_plan.id, 100], - [silver_plan.id, 150], - [gold_plan.id, 200] - ) - end - end - - describe '#down' do - it 'removes the project_feature_flags plan limits' do - migration.up - migration.down - - expect(plan_limits.pluck(:plan_id, :project_feature_flags)).to contain_exactly( - [default_plan.id, 200], - [free_plan.id, 0], - [bronze_plan.id, 0], - [silver_plan.id, 0], - [gold_plan.id, 0] - ) - end - end - end - - context 'when on self-hosted' do - before do - expect(Gitlab).to receive(:com?).at_most(:twice).and_return(false) - end - - describe '#up' do - it 'does not change the plan limits' do - migration.up - - expect(plan_limits.pluck(:project_feature_flags)).to contain_exactly(200) - end - end - - describe '#down' do - it 'does not change the plan limits' do - migration.up - migration.down - - expect(plan_limits.pluck(:project_feature_flags)).to contain_exactly(200) - end - end - end -end diff --git a/spec/migrations/migrate_all_merge_request_user_mentions_to_db_spec.rb b/spec/migrations/migrate_all_merge_request_user_mentions_to_db_spec.rb deleted file mode 100644 index c2df04bf2d6..00000000000 --- a/spec/migrations/migrate_all_merge_request_user_mentions_to_db_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateAllMergeRequestUserMentionsToDb, :migration do - let(:users) { table(:users) } - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - let(:merge_requests) { table(:merge_requests) } - let(:merge_request_user_mentions) { table(:merge_request_user_mentions) } - - let(:user) { users.create!(name: 'root', email: 'root@example.com', username: 'root', projects_limit: 0) } - let(:group) { namespaces.create!(name: 'group1', path: 'group1', owner_id: user.id, type: 'Group') } - let(:project) { projects.create!(name: 'gitlab1', path: 'gitlab1', namespace_id: group.id, visibility_level: 0) } - - let(:opened_state) { 1 } - let(:closed_state) { 2 } - let(:merged_state) { 3 } - - # migrateable resources - let(:common_args) { { source_branch: 'master', source_project_id: project.id, target_project_id: project.id, author_id: user.id, description: 'mr description with @root mention' } } - let!(:resource1) { merge_requests.create!(common_args.merge(title: "title 1", state_id: opened_state, target_branch: 'feature1')) } - let!(:resource2) { merge_requests.create!(common_args.merge(title: "title 2", state_id: closed_state, target_branch: 'feature2')) } - let!(:resource3) { merge_requests.create!(common_args.merge(title: "title 3", state_id: merged_state, target_branch: 'feature3')) } - - # non-migrateable resources - # this merge request is already migrated, as it has a record in the merge_request_user_mentions table - let!(:resource4) { merge_requests.create!(common_args.merge(title: "title 3", state_id: opened_state, target_branch: 'feature4')) } - let!(:user_mention) { merge_request_user_mentions.create!(merge_request_id: resource4.id, mentioned_users_ids: [1]) } - - let!(:resource5) { merge_requests.create!(common_args.merge(title: "title 3", description: 'description with no mention', state_id: opened_state, target_branch: 'feature5')) } - - it_behaves_like 'schedules resource mentions migration', MergeRequest, false -end diff --git a/spec/migrations/migrate_bot_type_to_user_type_spec.rb b/spec/migrations/migrate_bot_type_to_user_type_spec.rb deleted file mode 100644 index 54cf3450692..00000000000 --- a/spec/migrations/migrate_bot_type_to_user_type_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe MigrateBotTypeToUserType, :migration do - let(:users) { table(:users) } - - it 'updates bots & ignores humans' do - users.create!(email: 'human', bot_type: nil, projects_limit: 0) - users.create!(email: 'support_bot', bot_type: 1, projects_limit: 0) - users.create!(email: 'alert_bot', bot_type: 2, projects_limit: 0) - users.create!(email: 'visual_review_bot', bot_type: 3, projects_limit: 0) - - migrate! - - expect(users.where.not(user_type: nil).map(&:user_type)).to match_array([1, 2, 3]) - end -end diff --git a/spec/migrations/migrate_commit_notes_mentions_to_db_spec.rb b/spec/migrations/migrate_commit_notes_mentions_to_db_spec.rb deleted file mode 100644 index aa2aa6297c4..00000000000 --- a/spec/migrations/migrate_commit_notes_mentions_to_db_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateCommitNotesMentionsToDb, :migration, :sidekiq do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:notes) { table(:notes) } - - let(:user) { users.create!(name: 'root', email: 'root@example.com', username: 'root', projects_limit: 0) } - let(:group) { namespaces.create!(name: 'group1', path: 'group1', owner_id: user.id) } - let(:project) { projects.create!(name: 'gitlab1', path: 'gitlab1', namespace_id: group.id, visibility_level: 0) } - - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } - let(:commit) { Commit.new(RepoHelpers.sample_commit, project) } - let(:commit_user_mentions) { table(:commit_user_mentions) } - - let!(:resource1) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: user.id, note: 'note1 for @root to check') } - let!(:resource2) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: user.id, note: 'note1 for @root to check') } - let!(:resource3) { notes.create!(commit_id: commit.id, noteable_type: 'Commit', project_id: project.id, author_id: user.id, note: 'note1 for @root to check', system: true) } - - # non-migrateable resources - # this note is already migrated, as it has a record in the commit_user_mentions table - let!(:resource4) { notes.create!(note: 'note3 for @root to check', commit_id: commit.id, noteable_type: 'Commit') } - let!(:user_mention) { commit_user_mentions.create!(commit_id: commit.id, note_id: resource4.id, mentioned_users_ids: [1]) } - # this should have pointed to an inexistent commit record in a commits table - # but because commit is not an AR, we'll just make it so that the note does not have mentions, i.e. no `@` char. - let!(:resource5) { notes.create!(note: 'note3 to check', commit_id: 'abc', noteable_type: 'Commit') } - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - end - - it_behaves_like 'schedules resource mentions migration', Commit, true -end diff --git a/spec/migrations/migrate_compliance_framework_enum_to_database_framework_record_spec.rb b/spec/migrations/migrate_compliance_framework_enum_to_database_framework_record_spec.rb deleted file mode 100644 index 6a9a75a7019..00000000000 --- a/spec/migrations/migrate_compliance_framework_enum_to_database_framework_record_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateComplianceFrameworkEnumToDatabaseFrameworkRecord, schema: 20201005092753 do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:project_compliance_framework_settings) { table(:project_compliance_framework_settings) } - let(:compliance_management_frameworks) { table(:compliance_management_frameworks) } - - let(:gdpr_framework) { 1 } - let(:sox_framework) { 5 } - - let!(:root_group) { namespaces.create!(type: 'Group', name: 'a', path: 'a') } - let!(:sub_group) { namespaces.create!(type: 'Group', name: 'b', path: 'b', parent_id: root_group.id) } - let!(:sub_sub_group) { namespaces.create!(type: 'Group', name: 'c', path: 'c', parent_id: sub_group.id) } - - let!(:namespace) { namespaces.create!(name: 'd', path: 'd') } - - let!(:project_on_root_level) { projects.create!(namespace_id: root_group.id) } - let!(:project_on_sub_sub_level_1) { projects.create!(namespace_id: sub_sub_group.id) } - let!(:project_on_sub_sub_level_2) { projects.create!(namespace_id: sub_sub_group.id) } - let!(:project_on_namespace) { projects.create!(namespace_id: namespace.id) } - - let!(:project_on_root_level_compliance_setting) { project_compliance_framework_settings.create!(project_id: project_on_root_level.id, framework: gdpr_framework) } - let!(:project_on_sub_sub_level_compliance_setting_1) { project_compliance_framework_settings.create!(project_id: project_on_sub_sub_level_1.id, framework: sox_framework) } - let!(:project_on_sub_sub_level_compliance_setting_2) { project_compliance_framework_settings.create!(project_id: project_on_sub_sub_level_2.id, framework: gdpr_framework) } - let!(:project_on_namespace_level_compliance_setting) { project_compliance_framework_settings.create!(project_id: project_on_namespace.id, framework: gdpr_framework) } - - subject { described_class.new.up } - - it 'updates the project settings' do - subject - - gdpr_framework = compliance_management_frameworks.find_by(namespace_id: root_group.id, name: 'GDPR') - expect(project_on_root_level_compliance_setting.reload.framework_id).to eq(gdpr_framework.id) - expect(project_on_sub_sub_level_compliance_setting_2.reload.framework_id).to eq(gdpr_framework.id) - - sox_framework = compliance_management_frameworks.find_by(namespace_id: root_group.id, name: 'SOX') - expect(project_on_sub_sub_level_compliance_setting_1.reload.framework_id).to eq(sox_framework.id) - - gdpr_framework = compliance_management_frameworks.find_by(namespace_id: namespace.id, name: 'GDPR') - expect(project_on_namespace_level_compliance_setting.reload.framework_id).to eq(gdpr_framework.id) - end - - it 'adds two framework records' do - subject - - expect(compliance_management_frameworks.count).to eq(3) - end -end diff --git a/spec/migrations/migrate_create_commit_signature_worker_sidekiq_queue_spec.rb b/spec/migrations/migrate_create_commit_signature_worker_sidekiq_queue_spec.rb deleted file mode 100644 index 0e631f255bf..00000000000 --- a/spec/migrations/migrate_create_commit_signature_worker_sidekiq_queue_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateCreateCommitSignatureWorkerSidekiqQueue, :sidekiq, :redis do - include Gitlab::Database::MigrationHelpers - include StubWorker - - context 'when there are jobs in the queue' do - it 'correctly migrates queue when migrating up' do - Sidekiq::Testing.disable! do - stub_worker(queue: 'create_commit_signature').perform_async('Something', [1]) - stub_worker(queue: 'create_gpg_signature').perform_async('Something', [1]) - - described_class.new.up - - expect(sidekiq_queue_length('create_gpg_signature')).to eq 0 - expect(sidekiq_queue_length('create_commit_signature')).to eq 2 - end - end - - it 'correctly migrates queue when migrating down' do - Sidekiq::Testing.disable! do - stub_worker(queue: 'create_gpg_signature').perform_async('Something', [1]) - - described_class.new.down - - expect(sidekiq_queue_length('create_gpg_signature')).to eq 1 - expect(sidekiq_queue_length('create_commit_signature')).to eq 0 - end - end - end - - context 'when there are no jobs in the queues' do - it 'does not raise error when migrating up' do - expect { described_class.new.up }.not_to raise_error - end - - it 'does not raise error when migrating down' do - expect { described_class.new.down }.not_to raise_error - end - end -end diff --git a/spec/migrations/migrate_incident_issues_to_incident_type_spec.rb b/spec/migrations/migrate_incident_issues_to_incident_type_spec.rb deleted file mode 100644 index acac6114c71..00000000000 --- a/spec/migrations/migrate_incident_issues_to_incident_type_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateIncidentIssuesToIncidentType do - let(:migration) { described_class.new } - - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - let(:labels) { table(:labels) } - let(:issues) { table(:issues) } - let(:label_links) { table(:label_links) } - let(:label_props) { IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES } - - let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(namespace_id: namespace.id) } - let(:label) { labels.create!(project_id: project.id, **label_props) } - let!(:incident_issue) { issues.create!(project_id: project.id) } - let!(:other_issue) { issues.create!(project_id: project.id) } - - # Issue issue_type enum - let(:issue_type) { 0 } - let(:incident_type) { 1 } - - before do - label_links.create!(target_id: incident_issue.id, label_id: label.id, target_type: 'Issue') - end - - describe '#up' do - it 'updates the incident issue type' do - expect { migrate! } - .to change { incident_issue.reload.issue_type } - .from(issue_type) - .to(incident_type) - - expect(other_issue.reload.issue_type).to eql(issue_type) - end - end - - describe '#down' do - let!(:incident_issue) { issues.create!(project_id: project.id, issue_type: issue_type) } - - it 'updates the incident issue type' do - migration.up - - expect { migration.down } - .to change { incident_issue.reload.issue_type } - .from(incident_type) - .to(issue_type) - - expect(other_issue.reload.issue_type).to eql(issue_type) - end - end -end diff --git a/spec/migrations/migrate_merge_request_mentions_to_db_spec.rb b/spec/migrations/migrate_merge_request_mentions_to_db_spec.rb deleted file mode 100644 index 06493c4e5c1..00000000000 --- a/spec/migrations/migrate_merge_request_mentions_to_db_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateMergeRequestMentionsToDb, :migration do - let(:users) { table(:users) } - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - let(:merge_requests) { table(:merge_requests) } - let(:merge_request_user_mentions) { table(:merge_request_user_mentions) } - - let(:user) { users.create!(name: 'root', email: 'root@example.com', username: 'root', projects_limit: 0) } - let(:group) { namespaces.create!(name: 'group1', path: 'group1', owner_id: user.id, type: 'Group') } - let(:project) { projects.create!(name: 'gitlab1', path: 'gitlab1', namespace_id: group.id, visibility_level: 0) } - - # migrateable resources - let(:common_args) { { source_branch: 'master', source_project_id: project.id, target_project_id: project.id, author_id: user.id, description: 'mr description with @root mention' } } - let!(:resource1) { merge_requests.create!(common_args.merge(title: "title 1", state_id: 1, target_branch: 'feature1')) } - let!(:resource2) { merge_requests.create!(common_args.merge(title: "title 2", state_id: 1, target_branch: 'feature2')) } - let!(:resource3) { merge_requests.create!(common_args.merge(title: "title 3", state_id: 1, target_branch: 'feature3')) } - - # non-migrateable resources - # this merge request is already migrated, as it has a record in the merge_request_user_mentions table - let!(:resource4) { merge_requests.create!(common_args.merge(title: "title 3", state_id: 1, target_branch: 'feature3')) } - let!(:user_mention) { merge_request_user_mentions.create!(merge_request_id: resource4.id, mentioned_users_ids: [1]) } - - let!(:resource5) { merge_requests.create!(common_args.merge(title: "title 3", description: 'description with no mention', state_id: 1, target_branch: 'feature3')) } - - it_behaves_like 'schedules resource mentions migration', MergeRequest, false -end diff --git a/spec/migrations/migrate_store_security_reports_sidekiq_queue_spec.rb b/spec/migrations/migrate_store_security_reports_sidekiq_queue_spec.rb deleted file mode 100644 index 35cb6104fe2..00000000000 --- a/spec/migrations/migrate_store_security_reports_sidekiq_queue_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateStoreSecurityReportsSidekiqQueue, :redis do - include Gitlab::Database::MigrationHelpers - include StubWorker - - context 'when there are jobs in the queue' do - it 'migrates queue when migrating up' do - Sidekiq::Testing.disable! do - stub_worker(queue: 'pipeline_default:store_security_reports').perform_async(1, 5) - - described_class.new.up - - expect(sidekiq_queue_length('pipeline_default:store_security_reports')).to eq 0 - expect(sidekiq_queue_length('security_scans:store_security_reports')).to eq 1 - end - end - - it 'migrates queue when migrating down' do - Sidekiq::Testing.disable! do - stub_worker(queue: 'security_scans:store_security_reports').perform_async(1, 5) - - described_class.new.down - - expect(sidekiq_queue_length('pipeline_default:store_security_reports')).to eq 1 - expect(sidekiq_queue_length('security_scans:store_security_reports')).to eq 0 - end - end - end -end diff --git a/spec/migrations/migrate_sync_security_reports_to_report_approval_rules_sidekiq_queue_spec.rb b/spec/migrations/migrate_sync_security_reports_to_report_approval_rules_sidekiq_queue_spec.rb deleted file mode 100644 index a9e386301b8..00000000000 --- a/spec/migrations/migrate_sync_security_reports_to_report_approval_rules_sidekiq_queue_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateSyncSecurityReportsToReportApprovalRulesSidekiqQueue, :redis do - include Gitlab::Database::MigrationHelpers - include StubWorker - - context 'when there are jobs in the queue' do - it 'migrates queue when migrating up' do - Sidekiq::Testing.disable! do - stub_worker(queue: 'pipeline_default:sync_security_reports_to_report_approval_rules').perform_async(1, 5) - - described_class.new.up - - expect(sidekiq_queue_length('pipeline_default:sync_security_reports_to_report_approval_rules')).to eq 0 - expect(sidekiq_queue_length('security_scans:sync_security_reports_to_report_approval_rules')).to eq 1 - end - end - - it 'migrates queue when migrating down' do - Sidekiq::Testing.disable! do - stub_worker(queue: 'security_scans:sync_security_reports_to_report_approval_rules').perform_async(1, 5) - - described_class.new.down - - expect(sidekiq_queue_length('pipeline_default:sync_security_reports_to_report_approval_rules')).to eq 1 - expect(sidekiq_queue_length('security_scans:sync_security_reports_to_report_approval_rules')).to eq 0 - end - end - end -end diff --git a/spec/migrations/orphaned_invite_tokens_cleanup_spec.rb b/spec/migrations/orphaned_invite_tokens_cleanup_spec.rb index be5e7756514..b33e29f82e2 100644 --- a/spec/migrations/orphaned_invite_tokens_cleanup_spec.rb +++ b/spec/migrations/orphaned_invite_tokens_cleanup_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration! 'orphaned_invite_tokens_cleanup' +require_migration! RSpec.describe OrphanedInviteTokensCleanup, :migration do def create_member(**extra_attributes) diff --git a/spec/migrations/populate_remaining_missing_dismissal_information_for_vulnerabilities_spec.rb b/spec/migrations/populate_remaining_missing_dismissal_information_for_vulnerabilities_spec.rb deleted file mode 100644 index 986436971ac..00000000000 --- a/spec/migrations/populate_remaining_missing_dismissal_information_for_vulnerabilities_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe PopulateRemainingMissingDismissalInformationForVulnerabilities do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:vulnerabilities) { table(:vulnerabilities) } - - let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) } - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') } - - let(:states) { { detected: 1, dismissed: 2, resolved: 3, confirmed: 4 } } - let!(:vulnerability_1) { vulnerabilities.create!(title: 'title', state: states[:detected], severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) } - let!(:vulnerability_2) { vulnerabilities.create!(title: 'title', state: states[:dismissed], severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) } - let!(:vulnerability_3) { vulnerabilities.create!(title: 'title', state: states[:resolved], severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) } - let!(:vulnerability_4) { vulnerabilities.create!(title: 'title', state: states[:confirmed], severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) } - - describe '#perform' do - it 'calls the background migration class instance with broken vulnerability IDs' do - expect_next_instance_of(::Gitlab::BackgroundMigration::PopulateMissingVulnerabilityDismissalInformation) do |migrator| - expect(migrator).to receive(:perform).with(vulnerability_2.id) - end - - migrate! - end - end -end diff --git a/spec/migrations/remove_additional_application_settings_rows_spec.rb b/spec/migrations/remove_additional_application_settings_rows_spec.rb deleted file mode 100644 index d781195abf2..00000000000 --- a/spec/migrations/remove_additional_application_settings_rows_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe RemoveAdditionalApplicationSettingsRows do - let(:application_settings) { table(:application_settings) } - - it 'removes additional rows from application settings' do - 3.times { application_settings.create! } - latest_settings = application_settings.create! - - disable_migrations_output { migrate! } - - expect(application_settings.count).to eq(1) - expect(application_settings.first).to eq(latest_settings) - end - - it 'leaves only row in application_settings' do - latest_settings = application_settings.create! - - disable_migrations_output { migrate! } - - expect(application_settings.first).to eq(latest_settings) - end -end diff --git a/spec/migrations/remove_deprecated_jenkins_service_records_spec.rb b/spec/migrations/remove_deprecated_jenkins_service_records_spec.rb deleted file mode 100644 index 817cf183e0c..00000000000 --- a/spec/migrations/remove_deprecated_jenkins_service_records_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -require_migration!('ensure_deprecated_jenkins_service_records_removal') - -RSpec.shared_examples 'remove DeprecatedJenkinsService records' do - let(:services) { table(:services) } - - before do - services.create!(type: 'JenkinsDeprecatedService') - services.create!(type: 'JenkinsService') - end - - it 'deletes services when template and attached to a project' do - expect { migrate! } - .to change { services.where(type: 'JenkinsDeprecatedService').count }.from(1).to(0) - .and not_change { services.where(type: 'JenkinsService').count } - end -end - -RSpec.describe RemoveDeprecatedJenkinsServiceRecords, :migration do - it_behaves_like 'remove DeprecatedJenkinsService records' -end - -RSpec.describe EnsureDeprecatedJenkinsServiceRecordsRemoval, :migration do - it_behaves_like 'remove DeprecatedJenkinsService records' -end diff --git a/spec/migrations/remove_duplicate_labels_from_groups_spec.rb b/spec/migrations/remove_duplicate_labels_from_groups_spec.rb deleted file mode 100644 index 125314f70dd..00000000000 --- a/spec/migrations/remove_duplicate_labels_from_groups_spec.rb +++ /dev/null @@ -1,227 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('remove_duplicate_labels_from_group') - -RSpec.describe RemoveDuplicateLabelsFromGroup do - let(:labels_table) { table(:labels) } - let(:labels) { labels_table.all } - let(:projects_table) { table(:projects) } - let(:projects) { projects_table.all } - let(:namespaces_table) { table(:namespaces) } - let(:namespaces) { namespaces_table.all } - let(:backup_labels_table) { table(:backup_labels) } - let(:backup_labels) { backup_labels_table.all } - # for those cases where we can't use the activerecord class because the `type` column - # makes it think it has polymorphism and should be/have a Label subclass - let(:sql_backup_labels) { ApplicationRecord.connection.execute('SELECT * from backup_labels') } - - # all the possible tables with records that may have a relationship with a label - let(:analytics_cycle_analytics_group_stages_table) { table(:analytics_cycle_analytics_group_stages) } - let(:analytics_cycle_analytics_project_stages_table) { table(:analytics_cycle_analytics_project_stages) } - let(:board_labels_table) { table(:board_labels) } - let(:label_links_table) { table(:label_links) } - let(:label_priorities_table) { table(:label_priorities) } - let(:lists_table) { table(:lists) } - let(:resource_label_events_table) { table(:resource_label_events) } - - let!(:group_one) { namespaces_table.create!(id: 1, type: 'Group', name: 'group', path: 'group') } - let!(:project_one) do - projects_table.create!(id: 1, name: 'project', path: 'project', - visibility_level: 0, namespace_id: group_one.id) - end - - let(:label_title) { 'bug' } - let(:label_color) { 'red' } - let(:label_description) { 'nice label' } - let(:project_id) { project_one.id } - let(:group_id) { group_one.id } - let(:other_title) { 'feature' } - - let(:group_label_attributes) do - { - title: label_title, color: label_color, group_id: group_id, type: 'GroupLabel', template: false, description: label_description - } - end - - let(:migration) { described_class.new } - - describe 'removing full duplicates' do - context 'when there are no duplicate labels' do - let!(:first_label) { labels_table.create!(group_label_attributes.merge(id: 1, title: "a different label")) } - let!(:second_label) { labels_table.create!(group_label_attributes.merge(id: 2, title: "a totally different label")) } - - it 'does not remove anything' do - expect { migration.up }.not_to change { backup_labels_table.count } - end - - it 'restores removed records when rolling back - no change' do - migration.up - - expect { migration.down }.not_to change { labels_table.count } - end - end - - context 'with duplicates with no relationships' do - let!(:first_label) { labels_table.create!(group_label_attributes.merge(id: 1)) } - let!(:second_label) { labels_table.create!(group_label_attributes.merge(id: 2)) } - let!(:third_label) { labels_table.create!(group_label_attributes.merge(id: 3, title: other_title)) } - let!(:fourth_label) { labels_table.create!(group_label_attributes.merge(id: 4, title: other_title)) } - - it 'creates a backup record for each removed record' do - expect { migration.up }.to change { backup_labels_table.count }.from(0).to(2) - end - - it 'creates the correct backup records with `create` restore_action' do - migration.up - - expect(sql_backup_labels.find { |bl| bl["id"] == 2 }).to include(second_label.attributes.merge("restore_action" => described_class::CREATE, "new_title" => nil, "created_at" => anything, "updated_at" => anything)) - expect(sql_backup_labels.find { |bl| bl["id"] == 4 }).to include(fourth_label.attributes.merge("restore_action" => described_class::CREATE, "new_title" => nil, "created_at" => anything, "updated_at" => anything)) - end - - it 'deletes all but one' do - migration.up - - expect { second_label.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect { fourth_label.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'restores removed records on rollback' do - second_label_attributes = modified_attributes(second_label) - fourth_label_attributes = modified_attributes(fourth_label) - - migration.up - - migration.down - - expect(second_label.attributes).to include(second_label_attributes) - expect(fourth_label.attributes).to include(fourth_label_attributes) - end - end - - context 'two duplicate records, one of which has a relationship' do - let!(:first_label) { labels_table.create!(group_label_attributes.merge(id: 1)) } - let!(:second_label) { labels_table.create!(group_label_attributes.merge(id: 2)) } - let!(:label_priority) { label_priorities_table.create!(label_id: second_label.id, project_id: project_id, priority: 1) } - - it 'does not remove anything' do - expect { migration.up }.not_to change { labels_table.count } - end - - it 'does not create a backup record with `create` restore_action' do - expect { migration.up }.not_to change { backup_labels_table.where(restore_action: described_class::CREATE).count } - end - - it 'restores removed records when rolling back - no change' do - migration.up - - expect { migration.down }.not_to change { labels_table.count } - end - end - - context 'multiple duplicates, a subset of which have relationships' do - let!(:first_label) { labels_table.create!(group_label_attributes.merge(id: 1)) } - let!(:second_label) { labels_table.create!(group_label_attributes.merge(id: 2)) } - let!(:label_priority_for_second_label) { label_priorities_table.create!(label_id: second_label.id, project_id: project_id, priority: 1) } - let!(:third_label) { labels_table.create!(group_label_attributes.merge(id: 3)) } - let!(:fourth_label) { labels_table.create!(group_label_attributes.merge(id: 4)) } - let!(:label_priority_for_fourth_label) { label_priorities_table.create!(label_id: fourth_label.id, project_id: project_id, priority: 2) } - - it 'creates a backup record with `create` restore_action for each removed record' do - expect { migration.up }.to change { backup_labels_table.where(restore_action: described_class::CREATE).count }.from(0).to(1) - end - - it 'creates the correct backup records' do - migration.up - - expect(sql_backup_labels.find { |bl| bl["id"] == 3 }).to include(third_label.attributes.merge("restore_action" => described_class::CREATE, "new_title" => nil, "created_at" => anything, "updated_at" => anything)) - end - - it 'deletes the duplicate record' do - migration.up - - expect { first_label.reload }.not_to raise_error - expect { second_label.reload }.not_to raise_error - expect { third_label.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'restores removed records on rollback' do - third_label_attributes = modified_attributes(third_label) - - migration.up - migration.down - - expect(third_label.attributes).to include(third_label_attributes) - end - end - end - - describe 'renaming partial duplicates' do - # partial duplicates - only group_id and title match. Distinct colour prevents deletion. - context 'when there are no duplicate labels' do - let!(:first_label) { labels_table.create!(group_label_attributes.merge(id: 1, title: "a unique label", color: 'green')) } - let!(:second_label) { labels_table.create!(group_label_attributes.merge(id: 2, title: "a totally different, unique, label", color: 'blue')) } - - it 'does not rename anything' do - expect { migration.up }.not_to change { backup_labels_table.count } - end - end - - context 'with duplicates with no relationships' do - let!(:first_label) { labels_table.create!(group_label_attributes.merge(id: 1, color: 'green')) } - let!(:second_label) { labels_table.create!(group_label_attributes.merge(id: 2, color: 'blue')) } - let!(:third_label) { labels_table.create!(group_label_attributes.merge(id: 3, title: other_title, color: 'purple')) } - let!(:fourth_label) { labels_table.create!(group_label_attributes.merge(id: 4, title: other_title, color: 'yellow')) } - - it 'creates a backup record for each renamed record' do - expect { migration.up }.to change { backup_labels_table.count }.from(0).to(2) - end - - it 'creates the correct backup records with `rename` restore_action' do - migration.up - - expect(sql_backup_labels.find { |bl| bl["id"] == 2 }).to include(second_label.attributes.merge("restore_action" => described_class::RENAME, "created_at" => anything, "updated_at" => anything)) - expect(sql_backup_labels.find { |bl| bl["id"] == 4 }).to include(fourth_label.attributes.merge("restore_action" => described_class::RENAME, "created_at" => anything, "updated_at" => anything)) - end - - it 'modifies the titles of the partial duplicates' do - migration.up - - expect(second_label.reload.title).to match(/#{label_title}_duplicate#{second_label.id}$/) - expect(fourth_label.reload.title).to match(/#{other_title}_duplicate#{fourth_label.id}$/) - end - - it 'restores renamed records on rollback' do - second_label_attributes = modified_attributes(second_label) - fourth_label_attributes = modified_attributes(fourth_label) - - migration.up - - migration.down - - expect(second_label.reload.attributes).to include(second_label_attributes) - expect(fourth_label.reload.attributes).to include(fourth_label_attributes) - end - - context 'when the labels have a long title that might overflow' do - let(:long_title) { "a" * 255 } - - before do - first_label.update_attribute(:title, long_title) - second_label.update_attribute(:title, long_title) - end - - it 'keeps the length within the limit' do - migration.up - - expect(second_label.reload.title).to eq("#{"a" * 244}_duplicate#{second_label.id}") - expect(second_label.title.length).to eq(255) - end - end - end - end - - def modified_attributes(label) - label.attributes.except('created_at', 'updated_at') - end -end diff --git a/spec/migrations/remove_duplicate_labels_from_project_spec.rb b/spec/migrations/remove_duplicate_labels_from_project_spec.rb deleted file mode 100644 index eeb9f155e01..00000000000 --- a/spec/migrations/remove_duplicate_labels_from_project_spec.rb +++ /dev/null @@ -1,239 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemoveDuplicateLabelsFromProject do - let(:labels_table) { table(:labels) } - let(:labels) { labels_table.all } - let(:projects_table) { table(:projects) } - let(:projects) { projects_table.all } - let(:namespaces_table) { table(:namespaces) } - let(:namespaces) { namespaces_table.all } - let(:backup_labels_table) { table(:backup_labels) } - let(:backup_labels) { backup_labels_table.all } - - # all the possible tables with records that may have a relationship with a label - let(:analytics_cycle_analytics_group_stages_table) { table(:analytics_cycle_analytics_group_stages) } - let(:analytics_cycle_analytics_project_stages_table) { table(:analytics_cycle_analytics_project_stages) } - let(:board_labels_table) { table(:board_labels) } - let(:label_links_table) { table(:label_links) } - let(:label_priorities_table) { table(:label_priorities) } - let(:lists_table) { table(:lists) } - let(:resource_label_events_table) { table(:resource_label_events) } - - let!(:group_one) { namespaces_table.create!(id: 1, type: 'Group', name: 'group', path: 'group') } - let!(:project_one) do - projects_table.create!(id: 1, name: 'project', path: 'project', - visibility_level: 0, namespace_id: group_one.id) - end - - let(:label_title) { 'bug' } - let(:label_color) { 'red' } - let(:label_description) { 'nice label' } - let(:group_id) { group_one.id } - let(:project_id) { project_one.id } - let(:other_title) { 'feature' } - - let(:group_label_attributes) do - { - title: label_title, color: label_color, group_id: group_id, type: 'GroupLabel', template: false, description: label_description - } - end - - let(:project_label_attributes) do - { - title: label_title, color: label_color, project_id: project_id, type: 'ProjectLabel', template: false, description: label_description - } - end - - let(:migration) { described_class.new } - - describe 'removing full duplicates' do - context 'when there are no duplicate labels' do - let!(:first_label) { labels_table.create!(project_label_attributes.merge(id: 1, title: "a different label")) } - let!(:second_label) { labels_table.create!(project_label_attributes.merge(id: 2, title: "a totally different label")) } - - it 'does not remove anything' do - expect { migration.up }.not_to change { backup_labels_table.count } - end - - it 'restores removed records when rolling back - no change' do - migration.up - - expect { migration.down }.not_to change { labels_table.count } - end - end - - context 'with duplicates with no relationships' do - # can't use the activerecord class because the `type` makes it think it has polymorphism and should be/have a ProjectLabel subclass - let(:backup_labels) { ApplicationRecord.connection.execute('SELECT * from backup_labels') } - - let!(:first_label) { labels_table.create!(project_label_attributes.merge(id: 1)) } - let!(:second_label) { labels_table.create!(project_label_attributes.merge(id: 2)) } - let!(:third_label) { labels_table.create!(project_label_attributes.merge(id: 3, title: other_title)) } - let!(:fourth_label) { labels_table.create!(project_label_attributes.merge(id: 4, title: other_title)) } - - it 'creates a backup record for each removed record' do - expect { migration.up }.to change { backup_labels_table.count }.from(0).to(2) - end - - it 'creates the correct backup records with `create` restore_action' do - migration.up - - expect(backup_labels.find { |bl| bl["id"] == 2 }).to include(second_label.attributes.merge("restore_action" => described_class::CREATE, "new_title" => nil, "created_at" => anything, "updated_at" => anything)) - expect(backup_labels.find { |bl| bl["id"] == 4 }).to include(fourth_label.attributes.merge("restore_action" => described_class::CREATE, "new_title" => nil, "created_at" => anything, "updated_at" => anything)) - end - - it 'deletes all but one' do - migration.up - - expect { second_label.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect { fourth_label.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'restores removed records on rollback' do - second_label_attributes = modified_attributes(second_label) - fourth_label_attributes = modified_attributes(fourth_label) - - migration.up - - migration.down - - expect(second_label.attributes).to include(second_label_attributes) - expect(fourth_label.attributes).to include(fourth_label_attributes) - end - end - - context 'two duplicate records, one of which has a relationship' do - let!(:first_label) { labels_table.create!(project_label_attributes.merge(id: 1)) } - let!(:second_label) { labels_table.create!(project_label_attributes.merge(id: 2)) } - let!(:label_priority) { label_priorities_table.create!(label_id: second_label.id, project_id: project_id, priority: 1) } - - it 'does not remove anything' do - expect { migration.up }.not_to change { labels_table.count } - end - - it 'does not create a backup record with `create` restore_action' do - expect { migration.up }.not_to change { backup_labels_table.where(restore_action: described_class::CREATE).count } - end - - it 'restores removed records when rolling back - no change' do - migration.up - - expect { migration.down }.not_to change { labels_table.count } - end - end - - context 'multiple duplicates, a subset of which have relationships' do - let!(:first_label) { labels_table.create!(project_label_attributes.merge(id: 1)) } - let!(:second_label) { labels_table.create!(project_label_attributes.merge(id: 2)) } - let!(:label_priority_for_second_label) { label_priorities_table.create!(label_id: second_label.id, project_id: project_id, priority: 1) } - let!(:third_label) { labels_table.create!(project_label_attributes.merge(id: 3)) } - let!(:fourth_label) { labels_table.create!(project_label_attributes.merge(id: 4)) } - let!(:label_priority_for_fourth_label) { label_priorities_table.create!(label_id: fourth_label.id, project_id: project_id, priority: 2) } - - it 'creates a backup record with `create` restore_action for each removed record' do - expect { migration.up }.to change { backup_labels_table.where(restore_action: described_class::CREATE).count }.from(0).to(1) - end - - it 'creates the correct backup records' do - migration.up - - # can't use the activerecord class because the `type` column makes it think it has polymorphism and should be/have a ProjectLabel subclass - backup_labels = ApplicationRecord.connection.execute('SELECT * from backup_labels') - - expect(backup_labels.find { |bl| bl["id"] == 3 }).to include(third_label.attributes.merge("restore_action" => described_class::CREATE, "new_title" => nil, "created_at" => anything, "updated_at" => anything)) - end - - it 'deletes the duplicate record' do - migration.up - - expect { first_label.reload }.not_to raise_error - expect { second_label.reload }.not_to raise_error - expect { third_label.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'restores removed records on rollback' do - third_label_attributes = modified_attributes(third_label) - - migration.up - migration.down - - expect(third_label.attributes).to include(third_label_attributes) - end - end - end - - describe 'renaming partial duplicates' do - # partial duplicates - only project_id and title match. Distinct colour prevents deletion. - context 'when there are no duplicate labels' do - let!(:first_label) { labels_table.create!(project_label_attributes.merge(id: 1, title: "a unique label", color: 'green')) } - let!(:second_label) { labels_table.create!(project_label_attributes.merge(id: 2, title: "a totally different, unique, label", color: 'blue')) } - - it 'does not rename anything' do - expect { migration.up }.not_to change { backup_labels_table.count } - end - end - - context 'with duplicates with no relationships' do - let!(:first_label) { labels_table.create!(project_label_attributes.merge(id: 1, color: 'green')) } - let!(:second_label) { labels_table.create!(project_label_attributes.merge(id: 2, color: 'blue')) } - let!(:third_label) { labels_table.create!(project_label_attributes.merge(id: 3, title: other_title, color: 'purple')) } - let!(:fourth_label) { labels_table.create!(project_label_attributes.merge(id: 4, title: other_title, color: 'yellow')) } - - it 'creates a backup record for each renamed record' do - expect { migration.up }.to change { backup_labels_table.count }.from(0).to(2) - end - - it 'creates the correct backup records with `rename` restore_action' do - migration.up - - # can't use the activerecord class because the `type` makes it think it has polymorphism and should be/have a ProjectLabel subclass - backup_labels = ApplicationRecord.connection.execute('SELECT * from backup_labels') - - expect(backup_labels.find { |bl| bl["id"] == 2 }).to include(second_label.attributes.merge("restore_action" => described_class::RENAME, "created_at" => anything, "updated_at" => anything)) - expect(backup_labels.find { |bl| bl["id"] == 4 }).to include(fourth_label.attributes.merge("restore_action" => described_class::RENAME, "created_at" => anything, "updated_at" => anything)) - end - - it 'modifies the titles of the partial duplicates' do - migration.up - - expect(second_label.reload.title).to match(/#{label_title}_duplicate#{second_label.id}$/) - expect(fourth_label.reload.title).to match(/#{other_title}_duplicate#{fourth_label.id}$/) - end - - it 'restores renamed records on rollback' do - second_label_attributes = modified_attributes(second_label) - fourth_label_attributes = modified_attributes(fourth_label) - - migration.up - - migration.down - - expect(second_label.reload.attributes).to include(second_label_attributes) - expect(fourth_label.reload.attributes).to include(fourth_label_attributes) - end - - context 'when the labels have a long title that might overflow' do - let(:long_title) { "a" * 255 } - - before do - first_label.update_attribute(:title, long_title) - second_label.update_attribute(:title, long_title) - end - - it 'keeps the length within the limit' do - migration.up - - expect(second_label.reload.title).to eq("#{"a" * 244}_duplicate#{second_label.id}") - expect(second_label.title.length).to eq 255 - end - end - end - end - - def modified_attributes(label) - label.attributes.except('created_at', 'updated_at') - end -end diff --git a/spec/migrations/remove_gitlab_issue_tracker_service_records_spec.rb b/spec/migrations/remove_gitlab_issue_tracker_service_records_spec.rb deleted file mode 100644 index b4aa5187d4c..00000000000 --- a/spec/migrations/remove_gitlab_issue_tracker_service_records_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemoveGitlabIssueTrackerServiceRecords do - let(:services) { table(:services) } - - before do - 5.times { services.create!(type: 'GitlabIssueTrackerService') } - services.create!(type: 'SomeOtherType') - end - - it 'removes services records of type GitlabIssueTrackerService', :aggregate_failures do - expect { migrate! }.to change { services.count }.from(6).to(1) - expect(services.first.type).to eq('SomeOtherType') - expect(services.where(type: 'GitlabIssueTrackerService')).to be_empty - end -end diff --git a/spec/migrations/remove_orphan_service_hooks_spec.rb b/spec/migrations/remove_orphan_service_hooks_spec.rb deleted file mode 100644 index 71e70daf1e6..00000000000 --- a/spec/migrations/remove_orphan_service_hooks_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! -require_migration!('add_web_hooks_service_foreign_key') - -RSpec.describe RemoveOrphanServiceHooks, schema: 20201203123201 do - let(:web_hooks) { table(:web_hooks) } - let(:services) { table(:services) } - - before do - services.create! - web_hooks.create!(service_id: services.first.id, type: 'ServiceHook') - web_hooks.create!(service_id: nil) - - AddWebHooksServiceForeignKey.new.down - web_hooks.create!(service_id: non_existing_record_id, type: 'ServiceHook') - AddWebHooksServiceForeignKey.new.up - end - - it 'removes service hooks where the referenced service does not exist', :aggregate_failures do - expect { RemoveOrphanServiceHooks.new.up }.to change { web_hooks.count }.by(-1) - expect(web_hooks.where.not(service_id: services.select(:id)).count).to eq(0) - end -end diff --git a/spec/migrations/remove_orphaned_invited_members_spec.rb b/spec/migrations/remove_orphaned_invited_members_spec.rb deleted file mode 100644 index 67e98b69ccc..00000000000 --- a/spec/migrations/remove_orphaned_invited_members_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemoveOrphanedInvitedMembers do - let(:members_table) { table(:members) } - let(:users_table) { table(:users) } - let(:namespaces_table) { table(:namespaces) } - let(:projects_table) { table(:projects) } - - let!(:user1) { users_table.create!(name: 'user1', email: 'user1@example.com', projects_limit: 1) } - let!(:user2) { users_table.create!(name: 'user2', email: 'user2@example.com', projects_limit: 1) } - let!(:group) { namespaces_table.create!(type: 'Group', name: 'group', path: 'group') } - let!(:project) { projects_table.create!(name: 'project', path: 'project', namespace_id: group.id) } - - let!(:member1) { create_member(user_id: user1.id, source_type: 'Project', source_id: project.id, access_level: 10) } - let!(:member2) { create_member(user_id: user2.id, source_type: 'Group', source_id: group.id, access_level: 20) } - - let!(:invited_member1) do - create_member(user_id: nil, source_type: 'Project', source_id: project.id, - invite_token: SecureRandom.hex, invite_accepted_at: Time.now, - access_level: 20) - end - - let!(:invited_member2) do - create_member(user_id: nil, source_type: 'Group', source_id: group.id, - invite_token: SecureRandom.hex, invite_accepted_at: Time.now, - access_level: 20) - end - - let!(:orphaned_member1) do - create_member(user_id: nil, source_type: 'Project', source_id: project.id, - invite_accepted_at: Time.now, access_level: 30) - end - - let!(:orphaned_member2) do - create_member(user_id: nil, source_type: 'Group', source_id: group.id, - invite_accepted_at: Time.now, access_level: 20) - end - - it 'removes orphaned invited members but keeps current members' do - expect { migrate! }.to change { members_table.count }.from(6).to(4) - - expect(members_table.all.pluck(:id)).to contain_exactly(member1.id, member2.id, invited_member1.id, invited_member2.id) - end - - def create_member(options) - members_table.create!( - { - notification_level: 0, - ldap: false, - override: false - }.merge(options) - ) - end -end diff --git a/spec/migrations/remove_packages_deprecated_dependencies_spec.rb b/spec/migrations/remove_packages_deprecated_dependencies_spec.rb deleted file mode 100644 index f76a26bcdc1..00000000000 --- a/spec/migrations/remove_packages_deprecated_dependencies_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemovePackagesDeprecatedDependencies do - let(:projects) { table(:projects) } - let(:packages) { table(:packages_packages) } - let(:dependency_links) { table(:packages_dependency_links) } - let(:dependencies) { table(:packages_dependencies) } - - before do - projects.create!(id: 123, name: 'gitlab', path: 'gitlab-org/gitlab-ce', namespace_id: 1) - packages.create!(id: 1, name: 'package', version: '1.0.0', package_type: 4, project_id: 123) - 5.times do |i| - dependencies.create!(id: i, name: "pkg_dependency_#{i}", version_pattern: '~1.0.0') - dependency_links.create!(package_id: 1, dependency_id: i, dependency_type: 5) - end - dependencies.create!(id: 10, name: 'valid_pkg_dependency', version_pattern: '~2.5.0') - dependency_links.create!(package_id: 1, dependency_id: 10, dependency_type: 1) - end - - it 'removes all dependency links with type 5' do - expect(dependency_links.count).to eq 6 - - migrate! - - expect(dependency_links.count).to eq 1 - end -end diff --git a/spec/migrations/remove_security_dashboard_feature_flag_spec.rb b/spec/migrations/remove_security_dashboard_feature_flag_spec.rb deleted file mode 100644 index fea7fe01cc7..00000000000 --- a/spec/migrations/remove_security_dashboard_feature_flag_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe RemoveSecurityDashboardFeatureFlag do - let(:feature_gates) { table(:feature_gates) } - - subject(:migration) { described_class.new } - - describe '#up' do - it 'deletes the security_dashboard feature gate' do - security_dashboard_feature = feature_gates.create!(feature_key: :security_dashboard, key: :boolean, value: 'false') - actors_security_dashboard_feature = feature_gates.create!(feature_key: :security_dashboard, key: :actors, value: 'Project:1') - - migration.up - - expect { security_dashboard_feature.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect(actors_security_dashboard_feature.reload).to be_present - end - end - - describe '#down' do - it 'copies the instance_security_dashboard feature gate to a security_dashboard gate' do - feature_gates.create!(feature_key: :instance_security_dashboard, key: :actors, value: 'Project:1') - feature_gates.create!(feature_key: :instance_security_dashboard, key: 'boolean', value: 'false') - - migration.down - - security_dashboard_feature = feature_gates.find_by(feature_key: :security_dashboard, key: :boolean) - expect(security_dashboard_feature.value).to eq('false') - end - - context 'when there is no instance_security_dashboard gate' do - it 'does nothing' do - migration.down - - security_dashboard_feature = feature_gates.find_by(feature_key: :security_dashboard, key: :boolean) - expect(security_dashboard_feature).to be_nil - end - end - - context 'when there already is a security_dashboard gate' do - it 'does nothing' do - feature_gates.create!(feature_key: :security_dashboard, key: 'boolean', value: 'false') - feature_gates.create!(feature_key: :instance_security_dashboard, key: 'boolean', value: 'false') - - expect { migration.down }.not_to raise_error - end - end - end -end diff --git a/spec/migrations/rename_security_dashboard_feature_flag_to_instance_security_dashboard_spec.rb b/spec/migrations/rename_security_dashboard_feature_flag_to_instance_security_dashboard_spec.rb deleted file mode 100644 index fcbf94812fb..00000000000 --- a/spec/migrations/rename_security_dashboard_feature_flag_to_instance_security_dashboard_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe RenameSecurityDashboardFeatureFlagToInstanceSecurityDashboard do - let(:feature_gates) { table(:feature_gates) } - - subject(:migration) { described_class.new } - - describe '#up' do - it 'copies the security_dashboard feature gate to a new instance_security_dashboard gate' do - feature_gates.create!(feature_key: :security_dashboard, key: :actors, value: 'Project:1') - feature_gates.create!(feature_key: :security_dashboard, key: :boolean, value: 'false') - - migration.up - - instance_security_dashboard_feature = feature_gates.find_by(feature_key: :instance_security_dashboard, key: :boolean) - expect(instance_security_dashboard_feature.value).to eq('false') - end - - context 'when there is no security_dashboard gate' do - it 'does nothing' do - migration.up - - instance_security_dashboard_feature = feature_gates.find_by(feature_key: :instance_security_dashboard, key: :boolean) - expect(instance_security_dashboard_feature).to be_nil - end - end - - context 'when there is already an instance_security_dashboard gate' do - it 'does nothing' do - feature_gates.create!(feature_key: :security_dashboard, key: 'boolean', value: 'false') - feature_gates.create!(feature_key: :instance_security_dashboard, key: 'boolean', value: 'false') - - expect { migration.up }.not_to raise_error - end - end - end - - describe '#down' do - it 'removes the instance_security_dashboard gate' do - actors_instance_security_dashboard_feature = feature_gates.create!(feature_key: :instance_security_dashboard, key: :actors, value: 'Project:1') - instance_security_dashboard_feature = feature_gates.create!(feature_key: :instance_security_dashboard, key: :boolean, value: 'false') - - migration.down - - expect { instance_security_dashboard_feature.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect(actors_instance_security_dashboard_feature.reload).to be_present - end - end -end diff --git a/spec/migrations/rename_sitemap_namespace_spec.rb b/spec/migrations/rename_sitemap_namespace_spec.rb deleted file mode 100644 index 21b74587d50..00000000000 --- a/spec/migrations/rename_sitemap_namespace_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RenameSitemapNamespace do - let(:namespaces) { table(:namespaces) } - let(:routes) { table(:routes) } - let(:sitemap_path) { 'sitemap' } - - it 'correctly run #up and #down' do - create_namespace(sitemap_path) - - reversible_migration do |migration| - migration.before -> { - expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path) - } - - migration.after -> { - expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path + '0') - } - end - end - - def create_namespace(path) - namespaces.create!(name: path, path: path).tap do |namespace| - routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace') - end - end -end diff --git a/spec/migrations/rename_sitemap_root_namespaces_spec.rb b/spec/migrations/rename_sitemap_root_namespaces_spec.rb deleted file mode 100644 index 12a687194e0..00000000000 --- a/spec/migrations/rename_sitemap_root_namespaces_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RenameSitemapRootNamespaces do - let(:namespaces) { table(:namespaces) } - let(:routes) { table(:routes) } - let(:sitemap_path) { 'sitemap.xml' } - let(:sitemap_gz_path) { 'sitemap.xml.gz' } - let(:other_path1) { 'sitemap.xmlfoo' } - let(:other_path2) { 'foositemap.xml' } - - it 'correctly run #up and #down' do - create_namespace(sitemap_path) - create_namespace(sitemap_gz_path) - create_namespace(other_path1) - create_namespace(other_path2) - - reversible_migration do |migration| - migration.before -> { - expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path, sitemap_gz_path, other_path1, other_path2) - } - - migration.after -> { - expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path + '0', sitemap_gz_path + '0', other_path1, other_path2) - } - end - end - - def create_namespace(path) - namespaces.create!(name: path, path: path).tap do |namespace| - routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace') - end - end -end diff --git a/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb b/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb deleted file mode 100644 index fb629c90d9f..00000000000 --- a/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RescheduleSetDefaultIterationCadences do - let(:namespaces) { table(:namespaces) } - let(:iterations) { table(:sprints) } - - let(:group_1) { namespaces.create!(name: 'test_1', path: 'test_1') } - let!(:group_2) { namespaces.create!(name: 'test_2', path: 'test_2') } - let(:group_3) { namespaces.create!(name: 'test_3', path: 'test_3') } - let(:group_4) { namespaces.create!(name: 'test_4', path: 'test_4') } - let(:group_5) { namespaces.create!(name: 'test_5', path: 'test_5') } - let(:group_6) { namespaces.create!(name: 'test_6', path: 'test_6') } - let(:group_7) { namespaces.create!(name: 'test_7', path: 'test_7') } - let(:group_8) { namespaces.create!(name: 'test_8', path: 'test_8') } - - let!(:iteration_1) { iterations.create!(iid: 1, title: 'iteration 1', group_id: group_1.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } - let!(:iteration_2) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_3.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } - let!(:iteration_3) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_4.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } - let!(:iteration_4) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_5.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } - let!(:iteration_5) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_6.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } - let!(:iteration_6) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_7.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } - let!(:iteration_7) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_8.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } - - around do |example| - freeze_time { Sidekiq::Testing.fake! { example.run } } - end - - it 'schedules the background jobs', :aggregate_failures do - stub_const("#{described_class.name}::BATCH_SIZE", 3) - - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to be(3) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2.minutes, group_1.id, group_3.id, group_4.id) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(4.minutes, group_5.id, group_6.id, group_7.id) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(6.minutes, group_8.id) - end -end diff --git a/spec/migrations/reseed_merge_trains_enabled_spec.rb b/spec/migrations/reseed_merge_trains_enabled_spec.rb deleted file mode 100644 index 14ed44151d3..00000000000 --- a/spec/migrations/reseed_merge_trains_enabled_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ReseedMergeTrainsEnabled do - describe 'migrate' do - let(:project_ci_cd_settings) { table(:project_ci_cd_settings) } - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - - context 'when on Gitlab.com' do - before do - namespace = namespaces.create!(name: 'hello', path: 'hello/') - project1 = projects.create!(namespace_id: namespace.id) - project2 = projects.create!(namespace_id: namespace.id) - project_ci_cd_settings.create!(project_id: project1.id, merge_pipelines_enabled: true) - project_ci_cd_settings.create!(project_id: project2.id, merge_pipelines_enabled: false) - end - - it 'updates merge_trains_enabled to true for where merge_pipelines_enabled is true' do - expect { migrate! }.to change(project_ci_cd_settings.where(merge_trains_enabled: true), :count).by(1) - end - end - end -end diff --git a/spec/migrations/reseed_repository_storages_weighted_spec.rb b/spec/migrations/reseed_repository_storages_weighted_spec.rb deleted file mode 100644 index d7efff3dfba..00000000000 --- a/spec/migrations/reseed_repository_storages_weighted_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ReseedRepositoryStoragesWeighted do - let(:storages) { { "foo" => {}, "baz" => {} } } - let(:application_settings) do - table(:application_settings).tap do |klass| - klass.class_eval do - serialize :repository_storages - end - end - end - - before do - allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) - end - - let(:repository_storages) { ["foo"] } - let!(:application_setting) { application_settings.create!(repository_storages: repository_storages) } - - context 'with empty repository_storages_weighted column' do - it 'populates repository_storages_weighted properly' do - migrate! - - expect(application_settings.find(application_setting.id).repository_storages_weighted).to eq({ "foo" => 100, "baz" => 0 }) - end - end - - context 'with already-populated repository_storages_weighted column' do - let(:existing_weights) { { "foo" => 100, "baz" => 50 } } - - it 'does not change repository_storages_weighted properly' do - application_setting.repository_storages_weighted = existing_weights - application_setting.save! - - migrate! - - expect(application_settings.find(application_setting.id).repository_storages_weighted).to eq(existing_weights) - end - end -end diff --git a/spec/migrations/save_instance_administrators_group_id_spec.rb b/spec/migrations/save_instance_administrators_group_id_spec.rb deleted file mode 100644 index 0846df18b5e..00000000000 --- a/spec/migrations/save_instance_administrators_group_id_spec.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SaveInstanceAdministratorsGroupId do - let(:application_settings_table) { table(:application_settings) } - - let(:instance_administrators_group) do - table(:namespaces).create!( - id: 1, - name: 'GitLab Instance Administrators', - path: 'gitlab-instance-administrators-random', - type: 'Group' - ) - end - - let(:self_monitoring_project) do - table(:projects).create!( - id: 2, - name: 'Self Monitoring', - path: 'self_monitoring', - namespace_id: instance_administrators_group.id - ) - end - - context 'when project ID is saved but group ID is not' do - let(:application_settings) do - application_settings_table.create!(instance_administration_project_id: self_monitoring_project.id) - end - - it 'saves instance administrators group ID' do - expect(application_settings.instance_administration_project_id).to eq(self_monitoring_project.id) - expect(application_settings.instance_administrators_group_id).to be_nil - - migrate! - - expect(application_settings.reload.instance_administrators_group_id).to eq(instance_administrators_group.id) - expect(application_settings.instance_administration_project_id).to eq(self_monitoring_project.id) - end - end - - context 'when group ID is saved but project ID is not' do - let(:application_settings) do - application_settings_table.create!(instance_administrators_group_id: instance_administrators_group.id) - end - - it 'does not make changes' do - expect(application_settings.instance_administrators_group_id).to eq(instance_administrators_group.id) - expect(application_settings.instance_administration_project_id).to be_nil - - migrate! - - expect(application_settings.reload.instance_administrators_group_id).to eq(instance_administrators_group.id) - expect(application_settings.instance_administration_project_id).to be_nil - end - end - - context 'when group ID and project ID are both saved' do - let(:application_settings) do - application_settings_table.create!( - instance_administrators_group_id: instance_administrators_group.id, - instance_administration_project_id: self_monitoring_project.id - ) - end - - it 'does not make changes' do - expect(application_settings.instance_administrators_group_id).to eq(instance_administrators_group.id) - expect(application_settings.instance_administration_project_id).to eq(self_monitoring_project.id) - - migrate! - - expect(application_settings.reload.instance_administrators_group_id).to eq(instance_administrators_group.id) - expect(application_settings.instance_administration_project_id).to eq(self_monitoring_project.id) - end - end - - context 'when neither group ID nor project ID is saved' do - let(:application_settings) do - application_settings_table.create! - end - - it 'does not make changes' do - expect(application_settings.instance_administrators_group_id).to be_nil - expect(application_settings.instance_administration_project_id).to be_nil - - migrate! - - expect(application_settings.reload.instance_administrators_group_id).to be_nil - expect(application_settings.instance_administration_project_id).to be_nil - end - end - - context 'when application_settings table has no rows' do - it 'does not fail' do - migrate! - end - end -end diff --git a/spec/migrations/schedule_add_primary_email_to_emails_if_user_confirmed_spec.rb b/spec/migrations/schedule_add_primary_email_to_emails_if_user_confirmed_spec.rb new file mode 100644 index 00000000000..c66ac1bd7e9 --- /dev/null +++ b/spec/migrations/schedule_add_primary_email_to_emails_if_user_confirmed_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe ScheduleAddPrimaryEmailToEmailsIfUserConfirmed, :sidekiq do + let(:migration) { described_class.new } + let(:users) { table(:users) } + + let!(:user_1) { users.create!(name: 'confirmed-user-1', email: 'confirmed-1@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_2) { users.create!(name: 'confirmed-user-2', email: 'confirmed-2@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_3) { users.create!(name: 'confirmed-user-3', email: 'confirmed-3@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_4) { users.create!(name: 'confirmed-user-4', email: 'confirmed-4@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + stub_const("#{described_class.name}::INTERVAL", 2.minutes.to_i) + end + + it 'schedules addition of primary email to emails in delayed batches' do + Sidekiq::Testing.fake! do + freeze_time do + migration.up + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, user_1.id, user_2.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, user_3.id, user_4.id) + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end +end diff --git a/spec/migrations/schedule_backfill_push_rules_id_in_projects_spec.rb b/spec/migrations/schedule_backfill_push_rules_id_in_projects_spec.rb deleted file mode 100644 index 7b71110e62d..00000000000 --- a/spec/migrations/schedule_backfill_push_rules_id_in_projects_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe ScheduleBackfillPushRulesIdInProjects do - let(:push_rules) { table(:push_rules) } - - it 'adds global rule association to application settings' do - application_settings = table(:application_settings) - setting = application_settings.create! - sample_rule = push_rules.create!(is_sample: true) - - Sidekiq::Testing.fake! do - disable_migrations_output { migrate! } - end - - setting.reload - expect(setting.push_rule_id).to eq(sample_rule.id) - end - - it 'adds global rule association to last application settings when there is more than one record without failing' do - application_settings = table(:application_settings) - setting_old = application_settings.create! - setting = application_settings.create! - sample_rule = push_rules.create!(is_sample: true) - - Sidekiq::Testing.fake! do - disable_migrations_output { migrate! } - end - - expect(setting_old.reload.push_rule_id).to be_nil - expect(setting.reload.push_rule_id).to eq(sample_rule.id) - end - - it 'schedules worker to migrate project push rules' do - rule_1 = push_rules.create! - rule_2 = push_rules.create! - - Sidekiq::Testing.fake! do - disable_migrations_output { migrate! } - - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(5.minutes, rule_1.id, rule_2.id) - end - end -end diff --git a/spec/migrations/schedule_blocked_by_links_replacement_second_try_spec.rb b/spec/migrations/schedule_blocked_by_links_replacement_second_try_spec.rb deleted file mode 100644 index f2a0bdba32a..00000000000 --- a/spec/migrations/schedule_blocked_by_links_replacement_second_try_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleBlockedByLinksReplacementSecondTry do - let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') } - let(:project) { table(:projects).create!(namespace_id: namespace.id, name: 'gitlab') } - let(:issue1) { table(:issues).create!(project_id: project.id, title: 'a') } - let(:issue2) { table(:issues).create!(project_id: project.id, title: 'b') } - let(:issue3) { table(:issues).create!(project_id: project.id, title: 'c') } - let!(:issue_links) do - [ - table(:issue_links).create!(source_id: issue1.id, target_id: issue2.id, link_type: 1), - table(:issue_links).create!(source_id: issue2.id, target_id: issue1.id, link_type: 2), - table(:issue_links).create!(source_id: issue1.id, target_id: issue3.id, link_type: 2) - ] - end - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - end - - it 'schedules jobs for blocked_by links' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION).to be_scheduled_delayed_migration( - 2.minutes, issue_links[1].id, issue_links[1].id) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration( - 4.minutes, issue_links[2].id, issue_links[2].id) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end -end diff --git a/spec/migrations/schedule_link_lfs_objects_projects_spec.rb b/spec/migrations/schedule_link_lfs_objects_projects_spec.rb deleted file mode 100644 index 29c203c2c31..00000000000 --- a/spec/migrations/schedule_link_lfs_objects_projects_spec.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleLinkLfsObjectsProjects, :migration, :sidekiq do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:fork_networks) { table(:fork_networks) } - let(:fork_network_members) { table(:fork_network_members) } - let(:lfs_objects) { table(:lfs_objects) } - let(:lfs_objects_projects) { table(:lfs_objects_projects) } - - let(:namespace) { namespaces.create!(name: 'GitLab', path: 'gitlab') } - - let(:fork_network) { fork_networks.create!(root_project_id: source_project.id) } - let(:another_fork_network) { fork_networks.create!(root_project_id: another_source_project.id) } - - let(:source_project) { projects.create!(namespace_id: namespace.id) } - let(:another_source_project) { projects.create!(namespace_id: namespace.id) } - let(:project) { projects.create!(namespace_id: namespace.id) } - let(:another_project) { projects.create!(namespace_id: namespace.id) } - - let(:lfs_object) { lfs_objects.create!(oid: 'abc123', size: 100) } - let(:another_lfs_object) { lfs_objects.create!(oid: 'def456', size: 200) } - - let!(:source_project_lop_1) do - lfs_objects_projects.create!( - lfs_object_id: lfs_object.id, - project_id: source_project.id - ) - end - - let!(:source_project_lop_2) do - lfs_objects_projects.create!( - lfs_object_id: another_lfs_object.id, - project_id: source_project.id - ) - end - - let!(:another_source_project_lop_1) do - lfs_objects_projects.create!( - lfs_object_id: lfs_object.id, - project_id: another_source_project.id - ) - end - - let!(:another_source_project_lop_2) do - lfs_objects_projects.create!( - lfs_object_id: another_lfs_object.id, - project_id: another_source_project.id - ) - end - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - # Create links between projects - fork_network_members.create!(fork_network_id: fork_network.id, project_id: source_project.id, forked_from_project_id: nil) - fork_network_members.create!(fork_network_id: fork_network.id, project_id: project.id, forked_from_project_id: source_project.id) - fork_network_members.create!(fork_network_id: another_fork_network.id, project_id: another_source_project.id, forked_from_project_id: nil) - fork_network_members.create!(fork_network_id: another_fork_network.id, project_id: another_project.id, forked_from_project_id: another_fork_network.root_project_id) - end - - it 'schedules background migration to link LFS objects' do - Sidekiq::Testing.fake! do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes, source_project_lop_1.id, source_project_lop_2.id) - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(4.minutes, another_source_project_lop_1.id, another_source_project_lop_2.id) - end - end -end diff --git a/spec/migrations/schedule_merge_request_cleanup_schedules_backfill_spec.rb b/spec/migrations/schedule_merge_request_cleanup_schedules_backfill_spec.rb deleted file mode 100644 index 319c0802f2c..00000000000 --- a/spec/migrations/schedule_merge_request_cleanup_schedules_backfill_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe ScheduleMergeRequestCleanupSchedulesBackfill, :sidekiq, schema: 20201023114628 do - let(:merge_requests) { table(:merge_requests) } - let(:cleanup_schedules) { table(:merge_request_cleanup_schedules) } - - let(:namespace) { table(:namespaces).create!(name: 'name', path: 'path') } - let(:project) { table(:projects).create!(namespace_id: namespace.id) } - - describe '#up' do - let!(:open_mr) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master') } - - let!(:closed_mr_1) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 2) } - let!(:closed_mr_2) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 2) } - - let!(:merged_mr_1) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 3) } - let!(:merged_mr_2) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 3) } - - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - end - - it 'schedules BackfillMergeRequestCleanupSchedules background jobs' do - Sidekiq::Testing.fake! do - migrate! - - aggregate_failures do - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes, closed_mr_1.id, closed_mr_2.id) - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(4.minutes, merged_mr_1.id, merged_mr_2.id) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end -end diff --git a/spec/migrations/schedule_migrate_security_scans_spec.rb b/spec/migrations/schedule_migrate_security_scans_spec.rb deleted file mode 100644 index ce926241ba6..00000000000 --- a/spec/migrations/schedule_migrate_security_scans_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleMigrateSecurityScans, :sidekiq do - let(:migration) { described_class.new } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:builds) { table(:ci_builds) } - let(:job_artifacts) { table(:ci_job_artifacts) } - - let(:namespace) { namespaces.create!(name: "foo", path: "bar") } - let(:project) { projects.create!(namespace_id: namespace.id) } - let(:job) { builds.create! } - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - stub_const("#{described_class.name}::INTERVAL", 5.minutes.to_i) - end - - context 'no security job artifacts' do - before do - table(:ci_job_artifacts) - end - - it 'does not schedule migration' do - Sidekiq::Testing.fake! do - migrate! - - expect(BackgroundMigrationWorker.jobs).to be_empty - end - end - end - - context 'has security job artifacts' do - let!(:job_artifact_1) { job_artifacts.create!(project_id: project.id, job_id: job.id, file_type: 5) } - let!(:job_artifact_2) { job_artifacts.create!(project_id: project.id, job_id: job.id, file_type: 8) } - - it 'schedules migration of security scans' do - Sidekiq::Testing.fake! do - freeze_time do - migration.up - - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, job_artifact_1.id, job_artifact_1.id) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, job_artifact_2.id, job_artifact_2.id) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end - - context 'has non-security job artifacts' do - let!(:job_artifact_1) { job_artifacts.create!(project_id: project.id, job_id: job.id, file_type: 4) } - let!(:job_artifact_2) { job_artifacts.create!(project_id: project.id, job_id: job.id, file_type: 9) } - - it 'schedules migration of security scans' do - Sidekiq::Testing.fake! do - freeze_time do - migration.up - - expect(BackgroundMigrationWorker.jobs).to be_empty - end - end - end - end -end diff --git a/spec/migrations/schedule_migrate_u2f_webauthn_spec.rb b/spec/migrations/schedule_migrate_u2f_webauthn_spec.rb deleted file mode 100644 index 48f098e34fc..00000000000 --- a/spec/migrations/schedule_migrate_u2f_webauthn_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleMigrateU2fWebauthn do - let(:migration_name) { described_class::MIGRATION } - let(:u2f_registrations) { table(:u2f_registrations) } - let(:webauthn_registrations) { table(:webauthn_registrations) } - - let(:users) { table(:users) } - - let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) } - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - end - - context 'when there are u2f registrations' do - let!(:u2f_reg_1) { create_u2f_registration(1, 'reg1') } - let!(:u2f_reg_2) { create_u2f_registration(2, 'reg2') } - - it 'schedules a background migration' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(migration_name).to be_scheduled_delayed_migration(2.minutes, 1, 1) - expect(migration_name).to be_scheduled_delayed_migration(4.minutes, 2, 2) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end - - context 'when there are no u2f registrations' do - it 'does not schedule background migrations' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(0) - end - end - end - end - - def create_u2f_registration(id, name) - device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5)) - u2f_registrations.create!({ id: id, - certificate: Base64.strict_encode64(device.cert_raw), - key_handle: U2F.urlsafe_encode64(device.key_handle_raw), - public_key: Base64.strict_encode64(device.origin_public_key_raw), - counter: 5, - name: name, - user_id: user.id }) - end -end diff --git a/spec/migrations/schedule_populate_has_vulnerabilities_spec.rb b/spec/migrations/schedule_populate_has_vulnerabilities_spec.rb deleted file mode 100644 index edae7330b1e..00000000000 --- a/spec/migrations/schedule_populate_has_vulnerabilities_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SchedulePopulateHasVulnerabilities do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:vulnerabilities) { table(:vulnerabilities) } - let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) } - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let(:vulnerability_base_params) { { title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, author_id: user.id } } - let!(:project_1) { projects.create!(namespace_id: namespace.id, name: 'foo_1') } - let!(:project_2) { projects.create!(namespace_id: namespace.id, name: 'foo_2') } - let!(:project_3) { projects.create!(namespace_id: namespace.id, name: 'foo_3') } - - around do |example| - freeze_time { Sidekiq::Testing.fake! { example.run } } - end - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - - vulnerabilities.create!(vulnerability_base_params.merge(project_id: project_1.id)) - vulnerabilities.create!(vulnerability_base_params.merge(project_id: project_3.id)) - end - - it 'schedules the background jobs', :aggregate_failures do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to be(2) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2.minutes, project_1.id) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(4.minutes, project_3.id) - end -end diff --git a/spec/migrations/schedule_populate_issue_email_participants_spec.rb b/spec/migrations/schedule_populate_issue_email_participants_spec.rb deleted file mode 100644 index 3a7a4e4df1e..00000000000 --- a/spec/migrations/schedule_populate_issue_email_participants_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SchedulePopulateIssueEmailParticipants do - let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } - let!(:project) { table(:projects).create!(id: 1, namespace_id: namespace.id) } - let!(:issue1) { table(:issues).create!(id: 1, project_id: project.id, service_desk_reply_to: "a@gitlab.com") } - let!(:issue2) { table(:issues).create!(id: 2, project_id: project.id) } - let!(:issue3) { table(:issues).create!(id: 3, project_id: project.id, service_desk_reply_to: "b@gitlab.com") } - let!(:issue4) { table(:issues).create!(id: 4, project_id: project.id, service_desk_reply_to: "c@gitlab.com") } - let!(:issue5) { table(:issues).create!(id: 5, project_id: project.id, service_desk_reply_to: "d@gitlab.com") } - let(:issue_email_participants) { table(:issue_email_participants) } - - it 'correctly schedules background migrations' do - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes, 1, 3) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(4.minutes, 4, 5) - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end -end diff --git a/spec/migrations/schedule_populate_missing_dismissal_information_for_vulnerabilities_spec.rb b/spec/migrations/schedule_populate_missing_dismissal_information_for_vulnerabilities_spec.rb deleted file mode 100644 index e5934f2171f..00000000000 --- a/spec/migrations/schedule_populate_missing_dismissal_information_for_vulnerabilities_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SchedulePopulateMissingDismissalInformationForVulnerabilities do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:vulnerabilities) { table(:vulnerabilities) } - let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) } - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') } - - let!(:vulnerability_1) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) } - let!(:vulnerability_2) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id, dismissed_at: Time.now) } - let!(:vulnerability_3) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id, dismissed_by_id: user.id) } - let!(:vulnerability_4) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id, dismissed_at: Time.now, dismissed_by_id: user.id) } - let!(:vulnerability_5) { vulnerabilities.create!(title: 'title', state: 1, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) } - - around do |example| - freeze_time { Sidekiq::Testing.fake! { example.run } } - end - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - end - - it 'schedules the background jobs', :aggregate_failures do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to be(3) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(3.minutes, vulnerability_1.id) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(6.minutes, vulnerability_2.id) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(9.minutes, vulnerability_3.id) - end -end diff --git a/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb b/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb deleted file mode 100644 index 5f764a1ee8f..00000000000 --- a/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe SchedulePopulatePersonalSnippetStatistics do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:snippets) { table(:snippets) } - let(:projects) { table(:projects) } - let!(:user1) { users.create!(id: 1, email: 'user1@example.com', projects_limit: 10, username: 'test1', name: 'Test1', state: 'active') } - let!(:user2) { users.create!(id: 2, email: 'user2@example.com', projects_limit: 10, username: 'test2', name: 'Test2', state: 'active') } - let!(:user3) { users.create!(id: 3, email: 'user3@example.com', projects_limit: 10, username: 'test3', name: 'Test3', state: 'active') } - let!(:namespace1) { namespaces.create!(id: 1, owner_id: user1.id, name: 'test1', path: 'test1') } - let!(:namespace2) { namespaces.create!(id: 2, owner_id: user2.id, name: 'test2', path: 'test2') } - let!(:namespace3) { namespaces.create!(id: 3, owner_id: user3.id, name: 'test3', path: 'test3') } - - def create_snippet(id, user_id, type = 'PersonalSnippet') - params = { - id: id, - type: type, - author_id: user_id, - file_name: 'foo', - content: 'bar' - } - - snippets.create!(params) - end - - it 'correctly schedules background migrations' do - # Creating the snippets in different order - create_snippet(1, user1.id) - create_snippet(2, user2.id) - create_snippet(3, user1.id) - create_snippet(4, user3.id) - create_snippet(5, user3.id) - create_snippet(6, user1.id) - # Creating a project snippet to ensure we don't pick it - create_snippet(7, user1.id, 'ProjectSnippet') - - stub_const("#{described_class}::BATCH_SIZE", 4) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - aggregate_failures do - expect(described_class::MIGRATION) - .to be_scheduled_migration([1, 3, 6, 2]) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes, [4, 5]) - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end -end diff --git a/spec/migrations/schedule_populate_project_snippet_statistics_spec.rb b/spec/migrations/schedule_populate_project_snippet_statistics_spec.rb deleted file mode 100644 index 4ac107c5202..00000000000 --- a/spec/migrations/schedule_populate_project_snippet_statistics_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SchedulePopulateProjectSnippetStatistics do - let(:users) { table(:users) } - let(:snippets) { table(:snippets) } - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - let(:user1) { users.create!(id: 1, email: 'user1@example.com', projects_limit: 10, username: 'test1', name: 'Test1', state: 'active') } - let(:user2) { users.create!(id: 2, email: 'user2@example.com', projects_limit: 10, username: 'test2', name: 'Test2', state: 'active') } - let(:namespace1) { namespaces.create!(id: 1, owner_id: user1.id, name: 'user1', path: 'user1') } - let(:namespace2) { namespaces.create!(id: 2, owner_id: user2.id, name: 'user2', path: 'user2') } - let(:project1) { projects.create!(id: 1, namespace_id: namespace1.id) } - let(:project2) { projects.create!(id: 2, namespace_id: namespace1.id) } - let(:project3) { projects.create!(id: 3, namespace_id: namespace2.id) } - - def create_snippet(id, user_id, project_id, type = 'ProjectSnippet') - params = { - id: id, - type: type, - author_id: user_id, - project_id: project_id, - file_name: 'foo', - content: 'bar' - } - - snippets.create!(params) - end - - it 'correctly schedules background migrations' do - # Creating the snippets in different order - create_snippet(1, user1.id, project1.id) - create_snippet(2, user2.id, project3.id) - create_snippet(3, user1.id, project1.id) - create_snippet(4, user1.id, project2.id) - create_snippet(5, user2.id, project3.id) - create_snippet(6, user1.id, project1.id) - # Creating a personal snippet to ensure we don't pick it - create_snippet(7, user1.id, nil, 'PersonalSnippet') - - stub_const("#{described_class}::BATCH_SIZE", 4) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - aggregate_failures do - expect(described_class::MIGRATION) - .to be_scheduled_migration([1, 3, 6, 4]) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(2.minutes, [2, 5]) - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end -end diff --git a/spec/migrations/schedule_populate_user_highest_roles_table_spec.rb b/spec/migrations/schedule_populate_user_highest_roles_table_spec.rb deleted file mode 100644 index 0a2ee82b349..00000000000 --- a/spec/migrations/schedule_populate_user_highest_roles_table_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SchedulePopulateUserHighestRolesTable do - let(:users) { table(:users) } - - def create_user(id, params = {}) - user_params = { - id: id, - state: 'active', - user_type: nil, - bot_type: nil, - ghost: nil, - email: "user#{id}@example.com", - projects_limit: 0 - }.merge(params) - - users.create!(user_params) - end - - it 'correctly schedules background migrations' do - create_user(1) - create_user(2, state: 'blocked') - create_user(3, user_type: 2) - create_user(4) - create_user(5, bot_type: 1) - create_user(6, ghost: true) - create_user(7, ghost: false) - - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 1, 4) - - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 7, 7) - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end -end diff --git a/spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb b/spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb deleted file mode 100644 index 380d107250b..00000000000 --- a/spec/migrations/schedule_recalculate_project_authorizations_second_run_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleRecalculateProjectAuthorizationsSecondRun do - let(:users_table) { table(:users) } - - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - - 1.upto(4) do |i| - users_table.create!(id: i, name: "user#{i}", email: "user#{i}@example.com", projects_limit: 1) - end - end - - it 'schedules background migration' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_migration(1, 2) - expect(described_class::MIGRATION).to be_scheduled_migration(3, 4) - end - end - end -end diff --git a/spec/migrations/schedule_recalculate_project_authorizations_spec.rb b/spec/migrations/schedule_recalculate_project_authorizations_spec.rb deleted file mode 100644 index a4400c2ac83..00000000000 --- a/spec/migrations/schedule_recalculate_project_authorizations_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleRecalculateProjectAuthorizations do - let(:users_table) { table(:users) } - let(:namespaces_table) { table(:namespaces) } - let(:projects_table) { table(:projects) } - let(:project_authorizations_table) { table(:project_authorizations) } - - let(:user1) { users_table.create!(name: 'user1', email: 'user1@example.com', projects_limit: 1) } - let(:user2) { users_table.create!(name: 'user2', email: 'user2@example.com', projects_limit: 1) } - let(:group) { namespaces_table.create!(id: 1, type: 'Group', name: 'group', path: 'group') } - let(:project) do - projects_table.create!(id: 1, name: 'project', path: 'project', - visibility_level: 0, namespace_id: group.id) - end - - before do - stub_const("#{described_class}::BATCH_SIZE", 1) - - project_authorizations_table.create!(user_id: user1.id, project_id: project.id, access_level: 30) - project_authorizations_table.create!(user_id: user2.id, project_id: project.id, access_level: 30) - end - - it 'schedules background migration' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_migration([user1.id]) - expect(described_class::MIGRATION).to be_scheduled_migration([user2.id]) - end - end - end - - it 'ignores projects with higher id than maximum group id' do - another_user = users_table.create!(name: 'another user', email: 'another-user@example.com', - projects_limit: 1) - ignored_project = projects_table.create!(id: 2, name: 'ignored-project', path: 'ignored-project', - visibility_level: 0, namespace_id: group.id) - project_authorizations_table.create!(user_id: another_user.id, project_id: ignored_project.id, - access_level: 30) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_migration([user1.id]) - expect(described_class::MIGRATION).to be_scheduled_migration([user2.id]) - end - end - end -end diff --git a/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb b/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb deleted file mode 100644 index 302ae1d5ebe..00000000000 --- a/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleRecalculateProjectAuthorizationsThirdRun do - let(:users_table) { table(:users) } - - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - - 1.upto(4) do |i| - users_table.create!(id: i, name: "user#{i}", email: "user#{i}@example.com", projects_limit: 1) - end - end - - it 'schedules background migration' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_migration(1, 2) - expect(described_class::MIGRATION).to be_scheduled_migration(3, 4) - end - end - end -end diff --git a/spec/migrations/schedule_repopulate_historical_vulnerability_statistics_spec.rb b/spec/migrations/schedule_repopulate_historical_vulnerability_statistics_spec.rb deleted file mode 100644 index a65c94cf60e..00000000000 --- a/spec/migrations/schedule_repopulate_historical_vulnerability_statistics_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleRepopulateHistoricalVulnerabilityStatistics do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:project_settings) { table(:project_settings) } - - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let!(:project_1) { projects.create!(namespace_id: namespace.id, name: 'foo_1') } - let!(:project_2) { projects.create!(namespace_id: namespace.id, name: 'foo_2') } - let!(:project_3) { projects.create!(namespace_id: namespace.id, name: 'foo_3') } - let!(:project_4) { projects.create!(namespace_id: namespace.id, name: 'foo_4') } - - around do |example| - freeze_time { Sidekiq::Testing.fake! { example.run } } - end - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - - project_settings.create!(project_id: project_1.id, has_vulnerabilities: true) - project_settings.create!(project_id: project_2.id, has_vulnerabilities: false) - project_settings.create!(project_id: project_4.id, has_vulnerabilities: true) - end - - it 'schedules the background jobs', :aggregate_failures do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to be(2) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(described_class::DELAY_INTERVAL, [project_1.id], described_class::DAY_COUNT) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2 * described_class::DELAY_INTERVAL, [project_4.id], described_class::DAY_COUNT) - end -end diff --git a/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb b/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb deleted file mode 100644 index 8f265acccae..00000000000 --- a/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent do - include MigrationHelpers::NamespacesHelpers - let(:migration_class) { described_class::MIGRATION } - let(:migration_name) { migration_class.to_s.demodulize } - - context 'private visibility level' do - it 'correctly schedules background migrations' do - parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE) - create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - expect(migration_name).to be_scheduled_migration_with_multiple_args([parent.id], Gitlab::VisibilityLevel::PRIVATE) - end - end - end - - it 'correctly schedules background migrations for groups and subgroups' do - parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE) - middle_group = create_namespace('middle_group', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id) - create_namespace('middle_empty_group', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id) - create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - expect(migration_name).to be_scheduled_migration_with_multiple_args([middle_group.id, parent.id], Gitlab::VisibilityLevel::PRIVATE) - end - end - end - end - - context 'internal visibility level' do - it 'correctly schedules background migrations' do - parent = create_namespace('parent', Gitlab::VisibilityLevel::INTERNAL) - middle_group = create_namespace('child', Gitlab::VisibilityLevel::INTERNAL, parent_id: parent.id) - create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - expect(migration_name).to be_scheduled_migration_with_multiple_args([parent.id, middle_group.id], Gitlab::VisibilityLevel::INTERNAL) - end - end - end - end - - context 'mixed visibility levels' do - it 'correctly schedules background migrations' do - parent1 = create_namespace('parent1', Gitlab::VisibilityLevel::INTERNAL) - create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent1.id) - parent2 = create_namespace('parent2', Gitlab::VisibilityLevel::PRIVATE) - middle_group = create_namespace('middle_group', Gitlab::VisibilityLevel::INTERNAL, parent_id: parent2.id) - create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(migration_name).to be_scheduled_migration_with_multiple_args([parent1.id, middle_group.id], Gitlab::VisibilityLevel::INTERNAL) - expect(migration_name).to be_scheduled_migration_with_multiple_args([parent2.id], Gitlab::VisibilityLevel::PRIVATE) - end - end - end - end -end diff --git a/spec/migrations/schedule_update_existing_users_that_require_two_factor_auth_spec.rb b/spec/migrations/schedule_update_existing_users_that_require_two_factor_auth_spec.rb deleted file mode 100644 index a839229ec22..00000000000 --- a/spec/migrations/schedule_update_existing_users_that_require_two_factor_auth_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleUpdateExistingUsersThatRequireTwoFactorAuth do - let(:users) { table(:users) } - let!(:user_1) { users.create!(require_two_factor_authentication_from_group: true, name: "user1", email: "user1@example.com", projects_limit: 1) } - let!(:user_2) { users.create!(require_two_factor_authentication_from_group: false, name: "user2", email: "user2@example.com", projects_limit: 1) } - let!(:user_3) { users.create!(require_two_factor_authentication_from_group: true, name: "user3", email: "user3@example.com", projects_limit: 1) } - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - end - - it 'schedules jobs for users that require two factor authentication' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION).to be_scheduled_delayed_migration( - 2.minutes, user_1.id, user_1.id) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration( - 4.minutes, user_3.id, user_3.id) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end -end diff --git a/spec/migrations/seed_merge_trains_enabled_spec.rb b/spec/migrations/seed_merge_trains_enabled_spec.rb deleted file mode 100644 index 1cb0e3cf8a6..00000000000 --- a/spec/migrations/seed_merge_trains_enabled_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SeedMergeTrainsEnabled do - describe 'migrate' do - let(:project_ci_cd_settings) { table(:project_ci_cd_settings) } - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - - context 'when on Gitlab.com' do - before do - namespace = namespaces.create!(name: 'hello', path: 'hello/') - project1 = projects.create!(namespace_id: namespace.id) - project2 = projects.create!(namespace_id: namespace.id) - project_ci_cd_settings.create!(project_id: project1.id, merge_pipelines_enabled: true) - project_ci_cd_settings.create!(project_id: project2.id, merge_pipelines_enabled: false) - end - - it 'updates merge_trains_enabled to true for where merge_pipelines_enabled is true' do - migrate! - - expect(project_ci_cd_settings.where(merge_trains_enabled: true).count).to be(1) - end - end - end -end diff --git a/spec/migrations/seed_repository_storages_weighted_spec.rb b/spec/migrations/seed_repository_storages_weighted_spec.rb deleted file mode 100644 index 102107bcc9f..00000000000 --- a/spec/migrations/seed_repository_storages_weighted_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SeedRepositoryStoragesWeighted do - let(:storages) { { "foo" => {}, "baz" => {} } } - let(:application_settings) do - table(:application_settings).tap do |klass| - klass.class_eval do - serialize :repository_storages - end - end - end - - before do - allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) - end - - let(:application_setting) { application_settings.create! } - let(:repository_storages) { ["foo"] } - - it 'correctly schedules background migrations' do - application_setting.repository_storages = repository_storages - application_setting.save! - - migrate! - - expect(application_settings.find(application_setting.id).repository_storages_weighted).to eq({ "foo" => 100, "baz" => 0 }) - end -end diff --git a/spec/migrations/services_remove_temporary_index_on_project_id_spec.rb b/spec/migrations/services_remove_temporary_index_on_project_id_spec.rb deleted file mode 100644 index d47f6deb2d5..00000000000 --- a/spec/migrations/services_remove_temporary_index_on_project_id_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ServicesRemoveTemporaryIndexOnProjectId do - let(:migration_instance) { described_class.new } - - it 'adds and removes temporary partial index in up and down methods' do - reversible_migration do |migration| - migration.before -> { - expect(migration_instance.index_exists?(:services, :project_id, name: described_class::INDEX_NAME)).to be true - } - - migration.after -> { - expect(migration_instance.index_exists?(:services, :project_id, name: described_class::INDEX_NAME)).to be false - } - end - end - - describe '#up' do - context 'index does not exist' do - it 'skips removal action' do - migrate! - - expect { migrate! }.not_to change { migration_instance.index_exists?(:services, :project_id, name: described_class::INDEX_NAME) } - end - end - end - - describe '#down' do - context 'index already exists' do - it 'skips creation of duplicated temporary partial index on project_id' do - schema_migrate_down! - - expect { schema_migrate_down! }.not_to change { migration_instance.index_exists?(:services, :project_id, name: described_class::INDEX_NAME) } - end - end - end -end diff --git a/spec/migrations/set_job_waiter_ttl_spec.rb b/spec/migrations/set_job_waiter_ttl_spec.rb deleted file mode 100644 index a051f8a535c..00000000000 --- a/spec/migrations/set_job_waiter_ttl_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SetJobWaiterTtl, :redis do - it 'sets TTLs where necessary' do - waiter_with_ttl = Gitlab::JobWaiter.new.key - waiter_without_ttl = Gitlab::JobWaiter.new.key - key_with_ttl = "foo:bar" - key_without_ttl = "foo:qux" - - Gitlab::Redis::SharedState.with do |redis| - redis.set(waiter_with_ttl, "zzz", ex: 2000) - redis.set(waiter_without_ttl, "zzz") - redis.set(key_with_ttl, "zzz", ex: 2000) - redis.set(key_without_ttl, "zzz") - - described_class.new.up - - # This is the point of the migration. We know the migration uses a TTL of 21_600 - expect(redis.ttl(waiter_without_ttl)).to be > 20_000 - - # Other TTL's should be untouched by the migration - expect(redis.ttl(waiter_with_ttl)).to be_between(1000, 2000) - expect(redis.ttl(key_with_ttl)).to be_between(1000, 2000) - expect(redis.ttl(key_without_ttl)).to eq(-1) - end - end -end diff --git a/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb b/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb index 1fd19ee42b4..e03dd73ec8b 100644 --- a/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb +++ b/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration! 'slice_merge_request_diff_commit_migrations' +require_migration! RSpec.describe SliceMergeRequestDiffCommitMigrations, :migration do let(:migration) { described_class.new } diff --git a/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb b/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb index 3ad0b5a93c2..4fb4ba61a34 100644 --- a/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb +++ b/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -require_migration! 'steal_merge_request_diff_commit_users_migration' +require_migration! RSpec.describe StealMergeRequestDiffCommitUsersMigration, :migration do let(:migration) { described_class.new } diff --git a/spec/migrations/unconfirm_wrongfully_verified_emails_spec.rb b/spec/migrations/unconfirm_wrongfully_verified_emails_spec.rb deleted file mode 100644 index 5adc866d0a5..00000000000 --- a/spec/migrations/unconfirm_wrongfully_verified_emails_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe UnconfirmWrongfullyVerifiedEmails do - before do - user = table(:users).create!(name: 'user1', email: 'test1@test.com', projects_limit: 1) - table(:emails).create!(email: 'test2@test.com', user_id: user.id) - end - - context 'when email confirmation is enabled' do - before do - table(:application_settings).create!(send_user_confirmation_email: true) - end - - it 'enqueues WrongullyConfirmedEmailUnconfirmer job' do - Sidekiq::Testing.fake! do - migrate! - - jobs = BackgroundMigrationWorker.jobs - expect(jobs.size).to eq(1) - expect(jobs.first["args"].first).to eq(Gitlab::BackgroundMigration::WrongfullyConfirmedEmailUnconfirmer.name.demodulize) - end - end - end - - context 'when email confirmation is disabled' do - before do - table(:application_settings).create!(send_user_confirmation_email: false) - end - - it 'does not enqueue WrongullyConfirmedEmailUnconfirmer job' do - Sidekiq::Testing.fake! do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(0) - end - end - end - - context 'when email application setting record does not exist' do - before do - table(:application_settings).delete_all - end - - it 'does not enqueue WrongullyConfirmedEmailUnconfirmer job' do - Sidekiq::Testing.fake! do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(0) - end - end - end -end diff --git a/spec/migrations/update_application_setting_npm_package_requests_forwarding_default_spec.rb b/spec/migrations/update_application_setting_npm_package_requests_forwarding_default_spec.rb deleted file mode 100644 index be209536208..00000000000 --- a/spec/migrations/update_application_setting_npm_package_requests_forwarding_default_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe UpdateApplicationSettingNpmPackageRequestsForwardingDefault do - # Create test data - pipeline and CI/CD jobs. - let(:application_settings) { table(:application_settings) } - - before do - application_settings.create!(npm_package_requests_forwarding: false) - end - - # Test just the up migration. - it 'correctly migrates the application setting' do - expect { migrate! }.to change { current_application_setting }.from(false).to(true) - end - - # Test a reversible migration. - it 'correctly migrates up and down the application setting' do - reversible_migration do |migration| - # Expectations will run before the up migration, - # and then again after the down migration - migration.before -> { - expect(current_application_setting).to eq false - } - - # Expectations will run after the up migration. - migration.after -> { - expect(current_application_setting).to eq true - } - end - end - - def current_application_setting - ApplicationSetting.current_without_cache.npm_package_requests_forwarding - end -end diff --git a/spec/migrations/update_fingerprint_sha256_within_keys_spec.rb b/spec/migrations/update_fingerprint_sha256_within_keys_spec.rb deleted file mode 100644 index 22ec3135703..00000000000 --- a/spec/migrations/update_fingerprint_sha256_within_keys_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe UpdateFingerprintSha256WithinKeys do - let(:key_table) { table(:keys) } - - describe '#up' do - it 'the BackgroundMigrationWorker will be triggered and fingerprint_sha256 populated' do - key_table.create!( - id: 1, - user_id: 1, - title: 'test', - 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: nil - ) - - expect(Key.first.fingerprint_sha256).to eq(nil) - - described_class.new.up - - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - expect(BackgroundMigrationWorker.jobs.first["args"][0]).to eq("MigrateFingerprintSha256WithinKeys") - expect(BackgroundMigrationWorker.jobs.first["args"][1]).to eq([1, 1]) - end - end -end diff --git a/spec/migrations/update_historical_data_recorded_at_spec.rb b/spec/migrations/update_historical_data_recorded_at_spec.rb deleted file mode 100644 index 95d2bb989fd..00000000000 --- a/spec/migrations/update_historical_data_recorded_at_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe UpdateHistoricalDataRecordedAt do - let(:historical_data_table) { table(:historical_data) } - - it 'reversibly populates recorded_at from created_at or date' do - row1 = historical_data_table.create!( - date: Date.current - 1.day, - created_at: Time.current - 1.day - ) - - row2 = historical_data_table.create!(date: Date.current - 2.days) - row2.update!(created_at: nil) - - reversible_migration do |migration| - migration.before -> { - expect(row1.reload.recorded_at).to eq(nil) - expect(row2.reload.recorded_at).to eq(nil) - } - - migration.after -> { - expect(row1.reload.recorded_at).to eq(row1.created_at) - expect(row2.reload.recorded_at).to eq(row2.date.in_time_zone(Time.zone).change(hour: 12)) - } - end - end -end diff --git a/spec/migrations/update_internal_ids_last_value_for_epics_renamed_spec.rb b/spec/migrations/update_internal_ids_last_value_for_epics_renamed_spec.rb deleted file mode 100644 index d7d1781aaa2..00000000000 --- a/spec/migrations/update_internal_ids_last_value_for_epics_renamed_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe UpdateInternalIdsLastValueForEpicsRenamed, :migration, schema: 20201124185639 do - let(:namespaces) { table(:namespaces) } - let(:users) { table(:users) } - let(:epics) { table(:epics) } - let(:internal_ids) { table(:internal_ids) } - - let!(:author) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 0) } - let!(:group1) { namespaces.create!(type: 'Group', name: 'group1', path: 'group1') } - let!(:group2) { namespaces.create!(type: 'Group', name: 'group2', path: 'group2') } - let!(:group3) { namespaces.create!(type: 'Group', name: 'group3', path: 'group3') } - let!(:epic_last_value1) { internal_ids.create!(usage: 4, last_value: 5, namespace_id: group1.id) } - let!(:epic_last_value2) { internal_ids.create!(usage: 4, last_value: 5, namespace_id: group2.id) } - let!(:epic_last_value3) { internal_ids.create!(usage: 4, last_value: 5, namespace_id: group3.id) } - let!(:epic_1) { epics.create!(iid: 110, title: 'from epic 1', group_id: group1.id, author_id: author.id, title_html: 'any') } - let!(:epic_2) { epics.create!(iid: 5, title: 'from epic 1', group_id: group2.id, author_id: author.id, title_html: 'any') } - let!(:epic_3) { epics.create!(iid: 3, title: 'from epic 1', group_id: group3.id, author_id: author.id, title_html: 'any') } - - it 'updates out of sync internal_ids last_value' do - migrate! - - expect(internal_ids.find_by(usage: 4, namespace_id: group1.id).last_value).to eq(110) - expect(internal_ids.find_by(usage: 4, namespace_id: group2.id).last_value).to eq(5) - expect(internal_ids.find_by(usage: 4, namespace_id: group3.id).last_value).to eq(5) - end -end diff --git a/spec/migrations/update_routes_for_lost_and_found_group_and_orphaned_projects_spec.rb b/spec/migrations/update_routes_for_lost_and_found_group_and_orphaned_projects_spec.rb deleted file mode 100644 index 74e97b82363..00000000000 --- a/spec/migrations/update_routes_for_lost_and_found_group_and_orphaned_projects_spec.rb +++ /dev/null @@ -1,223 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe UpdateRoutesForLostAndFoundGroupAndOrphanedProjects, :migration do - let(:users) { table(:users) } - let(:namespaces) { table(:namespaces) } - let(:members) { table(:members) } - let(:projects) { table(:projects) } - let(:routes) { table(:routes) } - - before do - # Create a Ghost User and its namnespace, but skip the route - ghost_user = users.create!( - name: 'Ghost User', - username: 'ghost', - email: 'ghost@example.com', - user_type: described_class::User::USER_TYPE_GHOST, - projects_limit: 100, - state: :active, - bio: 'This is a "Ghost User"' - ) - - namespaces.create!( - name: 'Ghost User', - path: 'ghost', - owner_id: ghost_user.id, - visibility_level: 20 - ) - - # Create the 'lost-and-found', owned by the Ghost user, but with no route - lost_and_found_group = namespaces.create!( - name: described_class::User::LOST_AND_FOUND_GROUP, - path: described_class::User::LOST_AND_FOUND_GROUP, - type: 'Group', - description: 'Group to store orphaned projects', - visibility_level: 0 - ) - - members.create!( - type: 'GroupMember', - source_id: lost_and_found_group.id, - user_id: ghost_user.id, - source_type: 'Namespace', - access_level: described_class::User::ACCESS_LEVEL_OWNER, - notification_level: 3 - ) - - # Add an orphaned project under 'lost-and-found' but with the wrong path in its route - orphaned_project = projects.create!( - name: 'orphaned_project', - path: 'orphaned_project', - visibility_level: 20, - archived: false, - namespace_id: lost_and_found_group.id - ) - - routes.create!( - source_id: orphaned_project.id, - source_type: 'Project', - path: 'orphaned_project', - name: 'orphaned_project', - created_at: Time.current, - updated_at: Time.current - ) - - # Create another user named ghost which is not the Ghost User - # Also create a 'lost-and-found' group for them and add projects to it - # Purpose: test that the routes added for the 'lost-and-found' group and - # its projects are unique - fake_ghost_user = users.create!( - name: 'Ghost User', - username: 'ghost1', - email: 'ghost1@example.com', - user_type: nil, - projects_limit: 100, - state: :active, - bio: 'This is NOT a "Ghost User"' - ) - - fake_ghost_user_namespace = namespaces.create!( - name: 'Ghost User', - path: 'ghost1', - owner_id: fake_ghost_user.id, - visibility_level: 20 - ) - - routes.create!( - source_id: fake_ghost_user_namespace.id, - source_type: 'Namespace', - path: 'ghost1', - name: 'Ghost User', - created_at: Time.current, - updated_at: Time.current - ) - - fake_lost_and_found_group = namespaces.create!( - name: 'Lost and Found', - path: described_class::User::LOST_AND_FOUND_GROUP, # same path as the lost-and-found group - type: 'Group', - description: 'Fake lost and found group with the same path as the real one', - visibility_level: 20 - ) - - routes.create!( - source_id: fake_lost_and_found_group.id, - source_type: 'Namespace', - path: described_class::User::LOST_AND_FOUND_GROUP, # same path as the lost-and-found group - name: 'Lost and Found', - created_at: Time.current, - updated_at: Time.current - ) - - members.create!( - type: 'GroupMember', - source_id: fake_lost_and_found_group.id, - user_id: fake_ghost_user.id, - source_type: 'Namespace', - access_level: described_class::User::ACCESS_LEVEL_OWNER, - notification_level: 3 - ) - - normal_project = projects.create!( - name: 'normal_project', - path: 'normal_project', - visibility_level: 20, - archived: false, - namespace_id: fake_lost_and_found_group.id - ) - - routes.create!( - source_id: normal_project.id, - source_type: 'Project', - path: "#{described_class::User::LOST_AND_FOUND_GROUP}/normal_project", - name: 'Lost and Found / normal_project', - created_at: Time.current, - updated_at: Time.current - ) - - # Add a project whose route conflicts with the ghost username - # and should force the data migration to pick a new Ghost username and path - ghost_project = projects.create!( - name: 'Ghost Project', - path: 'ghost', - visibility_level: 20, - archived: false, - namespace_id: fake_lost_and_found_group.id - ) - - routes.create!( - source_id: ghost_project.id, - source_type: 'Project', - path: 'ghost', - name: 'Ghost Project', - created_at: Time.current, - updated_at: Time.current - ) - end - - it 'fixes the ghost user username and namespace path' do - ghost_user = users.find_by(user_type: described_class::User::USER_TYPE_GHOST) - ghost_namespace = namespaces.find_by(owner_id: ghost_user.id) - - expect(ghost_user.username).to eq('ghost') - expect(ghost_namespace.path).to eq('ghost') - - disable_migrations_output { migrate! } - - ghost_user = users.find_by(user_type: described_class::User::USER_TYPE_GHOST) - ghost_namespace = namespaces.find_by(owner_id: ghost_user.id) - ghost_namespace_route = routes.find_by(source_id: ghost_namespace.id, source_type: 'Namespace') - - expect(ghost_user.username).to eq('ghost2') - expect(ghost_namespace.path).to eq('ghost2') - expect(ghost_namespace_route.path).to eq('ghost2') - end - - it 'creates the route for the ghost user namespace' do - expect(routes.where(path: 'ghost').count).to eq(1) - expect(routes.where(path: 'ghost1').count).to eq(1) - expect(routes.where(path: 'ghost2').count).to eq(0) - - disable_migrations_output { migrate! } - - expect(routes.where(path: 'ghost').count).to eq(1) - expect(routes.where(path: 'ghost1').count).to eq(1) - expect(routes.where(path: 'ghost2').count).to eq(1) - end - - it 'fixes the path for the lost-and-found group by generating a unique one' do - expect(namespaces.where(path: described_class::User::LOST_AND_FOUND_GROUP).count).to eq(2) - - disable_migrations_output { migrate! } - - expect(namespaces.where(path: described_class::User::LOST_AND_FOUND_GROUP).count).to eq(1) - - lost_and_found_group = namespaces.find_by(name: described_class::User::LOST_AND_FOUND_GROUP) - expect(lost_and_found_group.path).to eq('lost-and-found1') - end - - it 'creates the route for the lost-and-found group' do - expect(routes.where(path: described_class::User::LOST_AND_FOUND_GROUP).count).to eq(1) - expect(routes.where(path: 'lost-and-found1').count).to eq(0) - - disable_migrations_output { migrate! } - - expect(routes.where(path: described_class::User::LOST_AND_FOUND_GROUP).count).to eq(1) - expect(routes.where(path: 'lost-and-found1').count).to eq(1) - end - - it 'updates the route for the orphaned project' do - orphaned_project_route = routes.find_by(path: 'orphaned_project') - expect(orphaned_project_route.name).to eq('orphaned_project') - - disable_migrations_output { migrate! } - - updated_route = routes.find_by(id: orphaned_project_route.id) - expect(updated_route.path).to eq('lost-and-found1/orphaned_project') - expect(updated_route.name).to eq("#{described_class::User::LOST_AND_FOUND_GROUP} / orphaned_project") - end -end diff --git a/spec/migrations/update_timestamp_softwarelicensespolicy_spec.rb b/spec/migrations/update_timestamp_softwarelicensespolicy_spec.rb deleted file mode 100644 index 0210f23f5c5..00000000000 --- a/spec/migrations/update_timestamp_softwarelicensespolicy_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe UpdateTimestampSoftwarelicensespolicy do - let(:software_licenses_policy) { table(:software_license_policies) } - let(:projects) { table(:projects) } - let(:licenses) { table(:software_licenses) } - - before do - projects.create!(name: 'gitlab', path: 'gitlab-org/gitlab-ce', namespace_id: 1) - licenses.create!(name: 'MIT') - software_licenses_policy.create!(project_id: projects.first.id, software_license_id: licenses.first.id, created_at: nil, updated_at: nil) - end - - it 'creates timestamps' do - migrate! - - expect(software_licenses_policy.first.created_at).to be_present - expect(software_licenses_policy.first.updated_at).to be_present - end -end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index e131661602e..bb8d476f257 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -425,9 +425,9 @@ RSpec.describe Ability do expect(keys).to include( :administrator, 'admin', - "/dp/condition/BasePolicy/admin/#{user_b.id}" + "/dp/condition/BasePolicy/admin/User:#{user_b.id}" ) - expect(keys).not_to include("/dp/condition/BasePolicy/admin/#{user_a.id}") + expect(keys).not_to include("/dp/condition/BasePolicy/admin/User:#{user_a.id}") end # regression spec for re-entrant admin condition checks diff --git a/spec/models/acts_as_taggable_on/tag_spec.rb b/spec/models/acts_as_taggable_on/tag_spec.rb new file mode 100644 index 00000000000..4b390bbd0bb --- /dev/null +++ b/spec/models/acts_as_taggable_on/tag_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActsAsTaggableOn::Tag do + it 'has the same connection as Ci::ApplicationRecord' do + query = 'select current_database()' + + expect(described_class.connection.execute(query).first).to eq(Ci::ApplicationRecord.connection.execute(query).first) + expect(described_class.retrieve_connection.execute(query).first).to eq(Ci::ApplicationRecord.retrieve_connection.execute(query).first) + end + + it 'has the same sticking as Ci::ApplicationRecord' do + expect(described_class.sticking).to eq(Ci::ApplicationRecord.sticking) + end +end diff --git a/spec/models/acts_as_taggable_on/tagging_spec.rb b/spec/models/acts_as_taggable_on/tagging_spec.rb new file mode 100644 index 00000000000..4520a0aaf70 --- /dev/null +++ b/spec/models/acts_as_taggable_on/tagging_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActsAsTaggableOn::Tagging do + it 'has the same connection as Ci::ApplicationRecord' do + query = 'select current_database()' + + expect(described_class.connection.execute(query).first).to eq(Ci::ApplicationRecord.connection.execute(query).first) + expect(described_class.retrieve_connection.execute(query).first).to eq(Ci::ApplicationRecord.retrieve_connection.execute(query).first) + end + + it 'has the same sticking as Ci::ApplicationRecord' do + expect(described_class.sticking).to eq(Ci::ApplicationRecord.sticking) + end +end diff --git a/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb b/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb index c0d5b9203b8..ac17271ff99 100644 --- a/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb +++ b/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb @@ -9,5 +9,12 @@ RSpec.describe Analytics::CycleAnalytics::IssueStageEvent do it { is_expected.to validate_presence_of(:project_id) } it { is_expected.to validate_presence_of(:start_event_timestamp) } - it_behaves_like 'StageEventModel' + it 'has state enum' do + expect(described_class.states).to eq(Issue.available_states) + end + + it_behaves_like 'StageEventModel' do + let_it_be(:stage_event_factory) { :cycle_analytics_issue_stage_event } + let_it_be(:issuable_factory) { :issue } + end end diff --git a/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb b/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb index 82a7e66d62a..bccc485d3f9 100644 --- a/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb +++ b/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb @@ -9,5 +9,12 @@ RSpec.describe Analytics::CycleAnalytics::MergeRequestStageEvent do it { is_expected.to validate_presence_of(:project_id) } it { is_expected.to validate_presence_of(:start_event_timestamp) } - it_behaves_like 'StageEventModel' + it 'has state enum' do + expect(described_class.states).to eq(MergeRequest.available_states) + end + + it_behaves_like 'StageEventModel' do + let_it_be(:stage_event_factory) { :cycle_analytics_merge_request_stage_event } + let_it_be(:issuable_factory) { :merge_request } + end end diff --git a/spec/models/blob_viewer/package_json_spec.rb b/spec/models/blob_viewer/package_json_spec.rb index 8a394a7334f..1dcba3bcb4f 100644 --- a/spec/models/blob_viewer/package_json_spec.rb +++ b/spec/models/blob_viewer/package_json_spec.rb @@ -27,11 +27,55 @@ RSpec.describe BlobViewer::PackageJson do end end - describe '#package_url' do - it 'returns the package URL' do - expect(subject).to receive(:prepare!) + context 'yarn' do + let(:data) do + <<-SPEC.strip_heredoc + { + "name": "module-name", + "version": "10.3.1", + "engines": { + "yarn": "^2.4.0" + } + } + SPEC + end + + let(:blob) { fake_blob(path: 'package.json', data: data) } + + subject { described_class.new(blob) } + + describe '#package_url' do + it 'returns the package URL', :aggregate_failures do + expect(subject).to receive(:prepare!) + + expect(subject.package_url).to eq("https://yarnpkg.com/package/#{subject.package_name}") + end + end - expect(subject.package_url).to eq("https://www.npmjs.com/package/#{subject.package_name}") + describe '#manager_url' do + it 'returns the manager URL', :aggregate_failures do + expect(subject).to receive(:prepare!) + + expect(subject.manager_url).to eq("https://yarnpkg.com/") + end + end + end + + context 'npm' do + describe '#package_url' do + it 'returns the package URL', :aggregate_failures do + expect(subject).to receive(:prepare!) + + expect(subject.package_url).to eq("https://www.npmjs.com/package/#{subject.package_name}") + end + end + + describe '#manager_url' do + it 'returns the manager URL', :aggregate_failures do + expect(subject).to receive(:prepare!) + + expect(subject.manager_url).to eq("https://www.npmjs.com/") + end end end diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb index 278d7f4bc56..cc66572cd6f 100644 --- a/spec/models/bulk_imports/entity_spec.rb +++ b/spec/models/bulk_imports/entity_spec.rb @@ -243,4 +243,13 @@ RSpec.describe BulkImports::Entity, type: :model do end end end + + describe '#relation_download_url_path' do + it 'returns export relations url with download query string' do + entity = build(:bulk_import_entity) + + expect(entity.relation_download_url_path('test')) + .to eq("/groups/#{entity.encoded_source_full_path}/export_relations/download?relation=test") + end + end end diff --git a/spec/models/bulk_imports/file_transfer/project_config_spec.rb b/spec/models/bulk_imports/file_transfer/project_config_spec.rb index 3bd79333f0c..02151da583e 100644 --- a/spec/models/bulk_imports/file_transfer/project_config_spec.rb +++ b/spec/models/bulk_imports/file_transfer/project_config_spec.rb @@ -80,7 +80,7 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do describe '#tree_relation_definition_for' do it 'returns relation definition' do - expected = { service_desk_setting: { except: [:outgoing_name, :file_template_project_id], include: [] } } + expected = { service_desk_setting: { except: [:outgoing_name, :file_template_project_id], include: [], only: %i[project_id issue_template_key project_key] } } expect(subject.tree_relation_definition_for('service_desk_setting')).to eq(expected) end diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb index 9ed00003ac1..67e0f98d147 100644 --- a/spec/models/chat_name_spec.rb +++ b/spec/models/chat_name_spec.rb @@ -43,4 +43,12 @@ RSpec.describe ChatName do expect(subject.last_used_at).to eq(time) end end + + it_behaves_like 'it has loose foreign keys' do + let(:factory_name) { :chat_name } + + before do + Ci::PipelineChatData # ensure that the referenced model is loaded + end + end end diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index 8f1ae9c5f02..6fde55103f8 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -17,8 +17,6 @@ RSpec.describe Ci::Bridge do { trigger: { project: 'my/project', branch: 'master' } } end - it { is_expected.to respond_to(:runner_features) } - it 'has many sourced pipelines' do expect(bridge).to have_many(:sourced_pipelines) end diff --git a/spec/models/ci/build_metadata_spec.rb b/spec/models/ci/build_metadata_spec.rb index 069864fa765..b2ffb34da1d 100644 --- a/spec/models/ci/build_metadata_spec.rb +++ b/spec/models/ci/build_metadata_spec.rb @@ -121,4 +121,16 @@ RSpec.describe Ci::BuildMetadata do end end end + + describe 'set_cancel_gracefully' do + it 'sets cancel_gracefully' do + build.set_cancel_gracefully + + expect(build.cancel_gracefully?).to be true + end + + it 'returns false' do + expect(build.cancel_gracefully?).to be false + end + end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 2ebf75a1d8a..b7de8ca4337 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -35,7 +35,8 @@ RSpec.describe Ci::Build do it { is_expected.to respond_to(:has_trace?) } it { is_expected.to respond_to(:trace) } - it { is_expected.to respond_to(:runner_features) } + it { is_expected.to respond_to(:set_cancel_gracefully) } + it { is_expected.to respond_to(:cancel_gracefully?) } it { is_expected.to delegate_method(:merge_request?).to(:pipeline) } it { is_expected.to delegate_method(:merge_request_ref?).to(:pipeline) } @@ -214,6 +215,26 @@ RSpec.describe Ci::Build do end end + describe '.license_management_jobs' do + subject { described_class.license_management_jobs } + + let!(:management_build) { create(:ci_build, :success, name: :license_management) } + let!(:scanning_build) { create(:ci_build, :success, name: :license_scanning) } + let!(:another_build) { create(:ci_build, :success, name: :another_type) } + + it 'returns license_scanning jobs' do + is_expected.to include(scanning_build) + end + + it 'returns license_management jobs' do + is_expected.to include(management_build) + end + + it 'doesnt return filtered out jobs' do + is_expected.not_to include(another_build) + end + end + describe '.finished_before' do subject { described_class.finished_before(date) } @@ -350,7 +371,7 @@ RSpec.describe Ci::Build do it 'sticks the build if the status changed' do job = create(:ci_build, :pending) - expect(ApplicationRecord.sticking).to receive(:stick) + expect(described_class.sticking).to receive(:stick) .with(:build, job.id) job.update!(status: :running) @@ -1290,7 +1311,7 @@ RSpec.describe Ci::Build do end end - shared_examples_for 'state transition as a deployable' do + describe 'state transition as a deployable' do subject { build.send(event) } let!(:build) { create(:ci_build, :with_deployment, :start_review_app, project: project, pipeline: pipeline) } @@ -1332,6 +1353,22 @@ RSpec.describe Ci::Build do expect(deployment).to be_running end + + context 'when deployment is already running state' do + before do + build.deployment.success! + end + + it 'does not change deployment status and tracks an error' do + expect(Gitlab::ErrorTracking) + .to receive(:track_exception).with( + instance_of(Deployment::StatusSyncError), deployment_id: deployment.id, build_id: build.id) + + with_cross_database_modification_prevented do + expect { subject }.not_to change { deployment.reload.status } + end + end + end end context 'when transits to success' do @@ -1399,36 +1436,6 @@ RSpec.describe Ci::Build do end end - it_behaves_like 'state transition as a deployable' do - context 'when transits to running' do - let(:event) { :run! } - - context 'when deployment is already running state' do - before do - build.deployment.success! - end - - it 'does not change deployment status and tracks an error' do - expect(Gitlab::ErrorTracking) - .to receive(:track_exception).with( - instance_of(Deployment::StatusSyncError), deployment_id: deployment.id, build_id: build.id) - - with_cross_database_modification_prevented do - expect { subject }.not_to change { deployment.reload.status } - end - end - end - end - end - - context 'when update_deployment_after_transaction_commit feature flag is disabled' do - before do - stub_feature_flags(update_deployment_after_transaction_commit: false) - end - - it_behaves_like 'state transition as a deployable' - end - describe '#on_stop' do subject { build.on_stop } @@ -2759,7 +2766,10 @@ RSpec.describe Ci::Build do let(:job_dependency_var) { { key: 'job_dependency', value: 'value', public: true, masked: false } } before do - allow(build).to receive(:predefined_variables) { [build_pre_var] } + allow_next_instance_of(Gitlab::Ci::Variables::Builder) do |builder| + allow(builder).to receive(:predefined_variables) { [build_pre_var] } + end + allow(build).to receive(:yaml_variables) { [build_yaml_var] } allow(build).to receive(:persisted_variables) { [] } allow(build).to receive(:job_jwt_variables) { [job_jwt_var] } @@ -3411,75 +3421,122 @@ RSpec.describe Ci::Build do end describe '#scoped_variables' do - context 'when build has not been persisted yet' do - let(:build) do - described_class.new( - name: 'rspec', - stage: 'test', - ref: 'feature', - project: project, - pipeline: pipeline, - scheduling_type: :stage - ) - end + before do + pipeline.clear_memoization(:predefined_vars_in_builder_enabled) + end - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'feature') } + it 'records a prometheus metric' do + histogram = double(:histogram) + expect(::Gitlab::Ci::Pipeline::Metrics).to receive(:pipeline_builder_scoped_variables_histogram) + .and_return(histogram) - it 'does not persist the build' do - expect(build).to be_valid - expect(build).not_to be_persisted + expect(histogram).to receive(:observe) + .with({}, a_kind_of(ActiveSupport::Duration)) - build.scoped_variables + build.scoped_variables + end - expect(build).not_to be_persisted - end + shared_examples 'calculates scoped_variables' do + context 'when build has not been persisted yet' do + let(:build) do + described_class.new( + name: 'rspec', + stage: 'test', + ref: 'feature', + project: project, + pipeline: pipeline, + scheduling_type: :stage + ) + end - it 'returns static predefined variables' do - keys = %w[CI_JOB_NAME - CI_COMMIT_SHA - CI_COMMIT_SHORT_SHA - CI_COMMIT_REF_NAME - CI_COMMIT_REF_SLUG - CI_JOB_STAGE] + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'feature') } - variables = build.scoped_variables + it 'does not persist the build' do + expect(build).to be_valid + expect(build).not_to be_persisted - variables.map { |env| env[:key] }.tap do |names| - expect(names).to include(*keys) + build.scoped_variables + + expect(build).not_to be_persisted end - expect(variables) - .to include(key: 'CI_COMMIT_REF_NAME', value: 'feature', public: true, masked: false) + it 'returns static predefined variables' do + keys = %w[CI_JOB_NAME + CI_COMMIT_SHA + CI_COMMIT_SHORT_SHA + CI_COMMIT_REF_NAME + CI_COMMIT_REF_SLUG + CI_JOB_STAGE] + + variables = build.scoped_variables + + variables.map { |env| env[:key] }.tap do |names| + expect(names).to include(*keys) + end + + expect(variables) + .to include(key: 'CI_COMMIT_REF_NAME', value: 'feature', public: true, masked: false) + end + + it 'does not return prohibited variables' do + keys = %w[CI_JOB_ID + CI_JOB_URL + CI_JOB_TOKEN + CI_BUILD_ID + CI_BUILD_TOKEN + CI_REGISTRY_USER + CI_REGISTRY_PASSWORD + CI_REPOSITORY_URL + CI_ENVIRONMENT_URL + CI_DEPLOY_USER + CI_DEPLOY_PASSWORD] + + build.scoped_variables.map { |env| env[:key] }.tap do |names| + expect(names).not_to include(*keys) + end + end end - it 'does not return prohibited variables' do - keys = %w[CI_JOB_ID - CI_JOB_URL - CI_JOB_TOKEN - CI_BUILD_ID - CI_BUILD_TOKEN - CI_REGISTRY_USER - CI_REGISTRY_PASSWORD - CI_REPOSITORY_URL - CI_ENVIRONMENT_URL - CI_DEPLOY_USER - CI_DEPLOY_PASSWORD] + context 'with dependency variables' do + let!(:prepare) { create(:ci_build, name: 'prepare', pipeline: pipeline, stage_idx: 0) } + let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['prepare'] }) } + + let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: prepare) } - build.scoped_variables.map { |env| env[:key] }.tap do |names| - expect(names).not_to include(*keys) + it 'inherits dependent variables' do + expect(build.scoped_variables.to_hash).to include(job_variable.key => job_variable.value) end end end - context 'with dependency variables' do - let!(:prepare) { create(:ci_build, name: 'prepare', pipeline: pipeline, stage_idx: 0) } - let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['prepare'] }) } + it_behaves_like 'calculates scoped_variables' - let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: prepare) } + it 'delegates to the variable builders' do + expect_next_instance_of(Gitlab::Ci::Variables::Builder) do |builder| + expect(builder) + .to receive(:scoped_variables).with(build, hash_including(:environment, :dependencies)) + .and_call_original - it 'inherits dependent variables' do - expect(build.scoped_variables.to_hash).to include(job_variable.key => job_variable.value) + expect(builder).to receive(:predefined_variables).and_call_original end + + build.scoped_variables + end + + context 'when ci builder feature flag is disabled' do + before do + stub_feature_flags(ci_predefined_vars_in_builder: false) + end + + it 'does not delegate to the variable builders' do + expect_next_instance_of(Gitlab::Ci::Variables::Builder) do |builder| + expect(builder).not_to receive(:predefined_variables) + end + + build.scoped_variables + end + + it_behaves_like 'calculates scoped_variables' end end @@ -3569,6 +3626,27 @@ RSpec.describe Ci::Build do include_examples "secret CI variables" end + describe '#kubernetes_variables' do + let(:build) { create(:ci_build) } + let(:service) { double(execute: template) } + let(:template) { double(to_yaml: 'example-kubeconfig', valid?: template_valid) } + let(:template_valid) { true } + + subject { build.kubernetes_variables } + + before do + allow(Ci::GenerateKubeconfigService).to receive(:new).with(build).and_return(service) + end + + it { is_expected.to include(key: 'KUBECONFIG', value: 'example-kubeconfig', public: false, file: true) } + + context 'generated config is invalid' do + let(:template_valid) { false } + + it { is_expected.not_to include(key: 'KUBECONFIG', value: 'example-kubeconfig', public: false, file: true) } + end + end + describe '#deployment_variables' do let(:build) { create(:ci_build, environment: environment) } let(:environment) { 'production' } @@ -3728,7 +3806,7 @@ RSpec.describe Ci::Build do it 'ensures that it is not run in database transaction' do expect(job.pipeline.persistent_ref).to receive(:create) do - expect(Gitlab::Database.main).not_to be_inside_transaction + expect(ApplicationRecord).not_to be_inside_transaction end run_job_without_exception @@ -5326,4 +5404,23 @@ RSpec.describe Ci::Build do create(:ci_build) end end + + describe '#runner_features' do + subject do + build.save! + build.cancel_gracefully? + end + + let_it_be(:build) { create(:ci_build, pipeline: pipeline) } + + it 'cannot cancel gracefully' do + expect(subject).to be false + end + + it 'can cancel gracefully' do + build.set_cancel_gracefully + + expect(subject).to be true + end + end end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index a94a1dd284a..d63f87e8943 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -351,6 +351,21 @@ RSpec.describe Ci::JobArtifact do end end + context 'when updating any field except the file' do + let(:artifact) { create(:ci_job_artifact, :unarchived_trace_artifact, file_store: 2) } + + before do + stub_artifacts_object_storage(direct_upload: true) + artifact.file.object_store = 1 + end + + it 'the `after_commit` hook does not update `file_store`' do + artifact.update!(expire_at: Time.current) + + expect(artifact.file_store).to be(2) + end + end + describe 'validates file format' do subject { artifact } @@ -507,6 +522,53 @@ RSpec.describe Ci::JobArtifact do end end + describe '#store_after_commit?' do + let(:file_type) { :archive } + let(:artifact) { build(:ci_job_artifact, file_type) } + + context 'when direct upload is enabled' do + before do + stub_artifacts_object_storage(direct_upload: true) + end + + context 'when the artifact is a trace' do + let(:file_type) { :trace } + + context 'when ci_store_trace_outside_transaction is enabled' do + it 'returns true' do + expect(artifact.store_after_commit?).to be_truthy + end + end + + context 'when ci_store_trace_outside_transaction is disabled' do + before do + stub_feature_flags(ci_store_trace_outside_transaction: false) + end + + it 'returns false' do + expect(artifact.store_after_commit?).to be_falsey + end + end + end + + context 'when the artifact is not a trace' do + it 'returns false' do + expect(artifact.store_after_commit?).to be_falsey + end + end + end + + context 'when direct upload is disabled' do + before do + stub_artifacts_object_storage(direct_upload: false) + end + + it 'returns false' do + expect(artifact.store_after_commit?).to be_falsey + end + end + end + describe 'file is being stored' do subject { create(:ci_job_artifact, :archive) } diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index c7e1fe91b1e..fee74f8f674 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Ci::PipelineSchedule do - let_it_be(:project) { create_default(:project) } + let_it_be_with_reload(:project) { create_default(:project) } subject { build(:ci_pipeline_schedule) } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 5f3aad0ab24..e573a6ef780 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -3361,7 +3361,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do shared_examples 'sending a notification' do it 'sends an email', :sidekiq_might_not_need_inline do - should_only_email(pipeline.user, kind: :bcc) + should_only_email(pipeline.user) end end @@ -4595,4 +4595,20 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end end end + + describe '#authorized_cluster_agents' do + let(:pipeline) { create(:ci_empty_pipeline, :created) } + let(:agent) { instance_double(Clusters::Agent) } + let(:authorization) { instance_double(Clusters::Agents::GroupAuthorization, agent: agent) } + let(:finder) { double(execute: [authorization]) } + + it 'retrieves agent records from the finder and caches the result' do + expect(Clusters::AgentAuthorizationsFinder).to receive(:new).once + .with(pipeline.project) + .and_return(finder) + + expect(pipeline.authorized_cluster_agents).to contain_exactly(agent) + expect(pipeline.authorized_cluster_agents).to contain_exactly(agent) # cached + end + end end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 826332268c5..2e79159cc60 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -5,6 +5,14 @@ require 'spec_helper' RSpec.describe Ci::Runner do it_behaves_like 'having unique enum values' + it_behaves_like 'it has loose foreign keys' do + let(:factory_name) { :ci_runner } + + before do + Clusters::Applications::Runner # ensure that the referenced model is loaded + end + end + describe 'groups association' do # Due to other assoctions such as projects this whole spec is allowed to # generate cross-database queries. So we have this temporary spec to @@ -44,7 +52,7 @@ RSpec.describe Ci::Runner do let(:runner) { create(:ci_runner, :group, groups: [group]) } it 'disallows assigning group if already assigned to a group' do - runner.groups << build(:group) + runner.runner_namespaces << build(:ci_runner_namespace) expect(runner).not_to be_valid expect(runner.errors.full_messages).to include('Runner needs to be assigned to exactly one group') @@ -397,7 +405,7 @@ RSpec.describe Ci::Runner do it 'sticks the runner to the primary and calls the original method' do runner = create(:ci_runner) - expect(ApplicationRecord.sticking).to receive(:stick) + expect(described_class.sticking).to receive(:stick) .with(:runner, runner.id) expect(Gitlab::Workhorse).to receive(:set_key_and_notify) @@ -618,7 +626,7 @@ RSpec.describe Ci::Runner do end describe '#status' do - let(:runner) { create(:ci_runner, :instance, contacted_at: 1.second.ago) } + let(:runner) { build(:ci_runner, :instance) } subject { runner.status } @@ -630,6 +638,45 @@ RSpec.describe Ci::Runner do it { is_expected.to eq(:not_connected) } end + context 'inactive but online' do + before do + runner.contacted_at = 1.second.ago + runner.active = false + end + + it { is_expected.to eq(:online) } + end + + context 'contacted 1s ago' do + before do + runner.contacted_at = 1.second.ago + end + + it { is_expected.to eq(:online) } + end + + context 'contacted long time ago' do + before do + runner.contacted_at = 1.year.ago + end + + it { is_expected.to eq(:offline) } + end + end + + describe '#deprecated_rest_status' do + let(:runner) { build(:ci_runner, :instance, contacted_at: 1.second.ago) } + + subject { runner.deprecated_rest_status } + + context 'never connected' do + before do + runner.contacted_at = nil + end + + it { is_expected.to eq(:not_connected) } + end + context 'contacted 1s ago' do before do runner.contacted_at = 1.second.ago diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index 4ba6c6e50f7..c254279a32f 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -57,4 +57,8 @@ RSpec.describe Ci::Trigger do it { is_expected.to eq(false) } end end + + it_behaves_like 'includes Limitable concern' do + subject { build(:ci_trigger, owner: project.owner, project: project) } + end end diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index 788430d53d3..806c60d5aff 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -112,7 +112,7 @@ RSpec.describe Clusters::Applications::Runner do subject expect(runner).to be_group_type - expect(runner.groups).to eq [group] + expect(runner.runner_namespaces.pluck(:namespace_id)).to match_array [group.id] end end @@ -162,12 +162,12 @@ RSpec.describe Clusters::Applications::Runner do it 'pauses associated runner' do active_runner = create(:ci_runner, contacted_at: 1.second.ago) - expect(active_runner.status).to eq(:online) + expect(active_runner.active).to be_truthy application_runner = create(:clusters_applications_runner, :scheduled, runner: active_runner) application_runner.prepare_uninstall - expect(active_runner.status).to eq(:paused) + expect(active_runner.active).to be_falsey end end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 9d305e31bad..d61bed80aaa 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -178,13 +178,13 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do end end - describe '.with_application_prometheus' do - subject { described_class.with_application_prometheus } + describe '.with_integration_prometheus' do + subject { described_class.with_integration_prometheus } let!(:cluster) { create(:cluster) } context 'cluster has prometheus application' do - let!(:application) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } + let!(:application) { create(:clusters_integrations_prometheus, cluster: cluster) } it { is_expected.to include(cluster) } end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 20afddd8470..59d14574c02 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -379,6 +379,22 @@ RSpec.describe CommitStatus do end end + describe '.retried_ordered' do + subject { described_class.retried_ordered.to_a } + + let!(:statuses) do + [create_status(name: 'aa', ref: 'bb', status: 'running', retried: true), + create_status(name: 'cc', ref: 'cc', status: 'pending', retried: true), + create_status(name: 'aa', ref: 'cc', status: 'success', retried: true), + create_status(name: 'cc', ref: 'bb', status: 'success'), + create_status(name: 'aa', ref: 'bb', status: 'success')] + end + + it 'returns retried statuses in order' do + is_expected.to eq(statuses.values_at(2, 0, 1)) + end + end + describe '.running_or_pending' do subject { described_class.running_or_pending.order(:id) } diff --git a/spec/models/concerns/bulk_insert_safe_spec.rb b/spec/models/concerns/bulk_insert_safe_spec.rb index 172986c142c..e6b197f34ca 100644 --- a/spec/models/concerns/bulk_insert_safe_spec.rb +++ b/spec/models/concerns/bulk_insert_safe_spec.rb @@ -5,42 +5,42 @@ require 'spec_helper' RSpec.describe BulkInsertSafe do before(:all) do ActiveRecord::Schema.define do - create_table :bulk_insert_parent_items, force: true do |t| + create_table :_test_bulk_insert_parent_items, force: true do |t| t.string :name, null: false end - create_table :bulk_insert_items, force: true do |t| + create_table :_test_bulk_insert_items, force: true do |t| t.string :name, null: true t.integer :enum_value, null: false t.text :encrypted_secret_value, null: false t.string :encrypted_secret_value_iv, null: false t.binary :sha_value, null: false, limit: 20 t.jsonb :jsonb_value, null: false - t.belongs_to :bulk_insert_parent_item, foreign_key: true, null: true + t.belongs_to :bulk_insert_parent_item, foreign_key: { to_table: :_test_bulk_insert_parent_items }, null: true t.timestamps null: true t.index :name, unique: true end - create_table :bulk_insert_items_with_composite_pk, id: false, force: true do |t| + create_table :_test_bulk_insert_items_with_composite_pk, id: false, force: true do |t| t.integer :id, null: true t.string :name, null: true end - execute("ALTER TABLE bulk_insert_items_with_composite_pk ADD PRIMARY KEY (id,name);") + execute("ALTER TABLE _test_bulk_insert_items_with_composite_pk ADD PRIMARY KEY (id,name);") end end after(:all) do ActiveRecord::Schema.define do - drop_table :bulk_insert_items, force: true - drop_table :bulk_insert_parent_items, force: true - drop_table :bulk_insert_items_with_composite_pk, force: true + drop_table :_test_bulk_insert_items, force: true + drop_table :_test_bulk_insert_parent_items, force: true + drop_table :_test_bulk_insert_items_with_composite_pk, force: true end end BulkInsertParentItem = Class.new(ActiveRecord::Base) do - self.table_name = :bulk_insert_parent_items + self.table_name = :_test_bulk_insert_parent_items self.inheritance_column = :_type_disabled def self.name @@ -54,7 +54,7 @@ RSpec.describe BulkInsertSafe do let_it_be(:bulk_insert_item_class) do Class.new(ActiveRecord::Base) do - self.table_name = 'bulk_insert_items' + self.table_name = '_test_bulk_insert_items' include BulkInsertSafe include ShaAttribute @@ -182,7 +182,7 @@ RSpec.describe BulkInsertSafe do context 'with returns option set' do let(:items) { bulk_insert_item_class.valid_list(1) } - subject(:bulk_insert) { bulk_insert_item_class.bulk_insert!(items, returns: returns) } + subject(:legacy_bulk_insert) { bulk_insert_item_class.bulk_insert!(items, returns: returns) } context 'when is set to :ids' do let(:returns) { :ids } @@ -247,7 +247,7 @@ RSpec.describe BulkInsertSafe do context 'when a model with composite primary key is inserted' do let_it_be(:bulk_insert_items_with_composite_pk_class) do Class.new(ActiveRecord::Base) do - self.table_name = 'bulk_insert_items_with_composite_pk' + self.table_name = '_test_bulk_insert_items_with_composite_pk' include BulkInsertSafe end diff --git a/spec/models/concerns/bulk_insertable_associations_spec.rb b/spec/models/concerns/bulk_insertable_associations_spec.rb index 25b13c8233d..9713f1ce9a4 100644 --- a/spec/models/concerns/bulk_insertable_associations_spec.rb +++ b/spec/models/concerns/bulk_insertable_associations_spec.rb @@ -6,42 +6,50 @@ RSpec.describe BulkInsertableAssociations do class BulkFoo < ApplicationRecord include BulkInsertSafe + self.table_name = '_test_bulk_foos' + validates :name, presence: true end class BulkBar < ApplicationRecord include BulkInsertSafe + + self.table_name = '_test_bulk_bars' end - SimpleBar = Class.new(ApplicationRecord) + SimpleBar = Class.new(ApplicationRecord) do + self.table_name = '_test_simple_bars' + end class BulkParent < ApplicationRecord include BulkInsertableAssociations - has_many :bulk_foos + self.table_name = '_test_bulk_parents' + + has_many :bulk_foos, class_name: 'BulkFoo' has_many :bulk_hunks, class_name: 'BulkFoo' - has_many :bulk_bars - has_many :simple_bars # not `BulkInsertSafe` + has_many :bulk_bars, class_name: 'BulkBar' + has_many :simple_bars, class_name: 'SimpleBar' # not `BulkInsertSafe` has_one :bulk_foo # not supported end before(:all) do ActiveRecord::Schema.define do - create_table :bulk_parents, force: true do |t| + create_table :_test_bulk_parents, force: true do |t| t.string :name, null: true end - create_table :bulk_foos, force: true do |t| + create_table :_test_bulk_foos, force: true do |t| t.string :name, null: true t.belongs_to :bulk_parent, null: false end - create_table :bulk_bars, force: true do |t| + create_table :_test_bulk_bars, force: true do |t| t.string :name, null: true t.belongs_to :bulk_parent, null: false end - create_table :simple_bars, force: true do |t| + create_table :_test_simple_bars, force: true do |t| t.string :name, null: true t.belongs_to :bulk_parent, null: false end @@ -50,10 +58,10 @@ RSpec.describe BulkInsertableAssociations do after(:all) do ActiveRecord::Schema.define do - drop_table :bulk_foos, force: true - drop_table :bulk_bars, force: true - drop_table :simple_bars, force: true - drop_table :bulk_parents, force: true + drop_table :_test_bulk_foos, force: true + drop_table :_test_bulk_bars, force: true + drop_table :_test_simple_bars, force: true + drop_table :_test_bulk_parents, force: true end end diff --git a/spec/models/concerns/cascading_namespace_setting_attribute_spec.rb b/spec/models/concerns/cascading_namespace_setting_attribute_spec.rb index e8f2b18e662..6be6e3f048f 100644 --- a/spec/models/concerns/cascading_namespace_setting_attribute_spec.rb +++ b/spec/models/concerns/cascading_namespace_setting_attribute_spec.rb @@ -136,6 +136,21 @@ RSpec.describe NamespaceSetting, 'CascadingNamespaceSettingAttribute' do .to raise_error(ActiveRecord::RecordInvalid, /Delayed project removal cannot be changed because it is locked by an ancestor/) end end + + context 'when parent locked the attribute then the application settings locks it' do + before do + subgroup_settings.update!(delayed_project_removal: true) + group_settings.update!(lock_delayed_project_removal: true, delayed_project_removal: false) + stub_application_setting(lock_delayed_project_removal: true, delayed_project_removal: true) + + subgroup_settings.clear_memoization(:delayed_project_removal) + subgroup_settings.clear_memoization(:delayed_project_removal_locked_ancestor) + end + + it 'returns the application setting value' do + expect(delayed_project_removal).to eq(true) + end + end end describe '#delayed_project_removal?' do diff --git a/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb b/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb new file mode 100644 index 00000000000..a4d1a33b3d5 --- /dev/null +++ b/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::AuthorizationConfigScopes do + describe '.with_available_ci_access_fields' do + let(:project) { create(:project) } + + let!(:agent_authorization_0) { create(:agent_project_authorization, project: project) } + let!(:agent_authorization_1) { create(:agent_project_authorization, project: project, config: { access_as: {} }) } + let!(:agent_authorization_2) { create(:agent_project_authorization, project: project, config: { access_as: { agent: {} } }) } + let!(:impersonate_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { impersonate: {} } }) } + let!(:ci_user_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { ci_user: {} } }) } + let!(:ci_job_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { ci_job: {} } }) } + let!(:unexpected_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { unexpected: {} } }) } + + subject { Clusters::Agents::ProjectAuthorization.with_available_ci_access_fields(project) } + + it { is_expected.to contain_exactly(agent_authorization_0, agent_authorization_1, agent_authorization_2) } + end +end diff --git a/spec/models/concerns/database_reflection_spec.rb b/spec/models/concerns/database_reflection_spec.rb new file mode 100644 index 00000000000..4111f29ea8d --- /dev/null +++ b/spec/models/concerns/database_reflection_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe DatabaseReflection do + describe '.reflect' do + it 'returns a Reflection instance' do + expect(User.database).to be_an_instance_of(Gitlab::Database::Reflection) + end + + it 'memoizes the result' do + instance1 = User.database + instance2 = User.database + + expect(instance1).to equal(instance2) + end + end +end diff --git a/spec/models/concerns/has_integrations_spec.rb b/spec/models/concerns/has_integrations_spec.rb deleted file mode 100644 index ea6b0e69209..00000000000 --- a/spec/models/concerns/has_integrations_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe HasIntegrations do - let_it_be(:project_1) { create(:project) } - let_it_be(:project_2) { create(:project) } - let_it_be(:project_3) { create(:project) } - let_it_be(:project_4) { create(:project) } - let_it_be(:instance_integration) { create(:jira_integration, :instance) } - - before do - create(:jira_integration, project: project_1, inherit_from_id: instance_integration.id) - create(:jira_integration, project: project_2, inherit_from_id: nil) - create(:jira_integration, group: create(:group), project: nil, inherit_from_id: nil) - create(:jira_integration, project: project_3, inherit_from_id: nil) - create(:integrations_slack, project: project_4, inherit_from_id: nil) - end - - describe '.without_integration' do - it 'returns projects without integration' do - expect(Project.without_integration(instance_integration)).to contain_exactly(project_4) - end - end -end diff --git a/spec/models/concerns/legacy_bulk_insert_spec.rb b/spec/models/concerns/legacy_bulk_insert_spec.rb new file mode 100644 index 00000000000..0c6f84f391b --- /dev/null +++ b/spec/models/concerns/legacy_bulk_insert_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# rubocop: disable Gitlab/BulkInsert +RSpec.describe LegacyBulkInsert do + let(:model) { ApplicationRecord } + + describe '#bulk_insert' do + before do + allow(model).to receive(:connection).and_return(dummy_connection) + allow(dummy_connection).to receive(:quote_column_name, &:itself) + allow(dummy_connection).to receive(:quote, &:itself) + allow(dummy_connection).to receive(:execute) + end + + let(:dummy_connection) { double(:connection) } + + let(:rows) do + [ + { a: 1, b: 2, c: 3 }, + { c: 6, a: 4, b: 5 } + ] + end + + it 'does nothing with empty rows' do + expect(dummy_connection).not_to receive(:execute) + + model.legacy_bulk_insert('test', []) + end + + it 'uses the ordering from the first row' do + expect(dummy_connection).to receive(:execute) do |sql| + expect(sql).to include('(1, 2, 3)') + expect(sql).to include('(4, 5, 6)') + end + + model.legacy_bulk_insert('test', rows) + end + + it 'quotes column names' do + expect(dummy_connection).to receive(:quote_column_name).with(:a) + expect(dummy_connection).to receive(:quote_column_name).with(:b) + expect(dummy_connection).to receive(:quote_column_name).with(:c) + + model.legacy_bulk_insert('test', rows) + end + + it 'quotes values' do + 1.upto(6) do |i| + expect(dummy_connection).to receive(:quote).with(i) + end + + model.legacy_bulk_insert('test', rows) + end + + it 'does not quote values of a column in the disable_quote option' do + [1, 2, 4, 5].each do |i| + expect(dummy_connection).to receive(:quote).with(i) + end + + model.legacy_bulk_insert('test', rows, disable_quote: :c) + end + + it 'does not quote values of columns in the disable_quote option' do + [2, 5].each do |i| + expect(dummy_connection).to receive(:quote).with(i) + end + + model.legacy_bulk_insert('test', rows, disable_quote: [:a, :c]) + end + + it 'handles non-UTF-8 data' do + expect { model.legacy_bulk_insert('test', [{ a: "\255" }]) }.not_to raise_error + end + + context 'when using PostgreSQL' do + it 'allows the returning of the IDs of the inserted rows' do + result = double(:result, values: [['10']]) + + expect(dummy_connection) + .to receive(:execute) + .with(/RETURNING id/) + .and_return(result) + + ids = model + .legacy_bulk_insert('test', [{ number: 10 }], return_ids: true) + + expect(ids).to eq([10]) + end + + it 'allows setting the upsert to do nothing' do + expect(dummy_connection) + .to receive(:execute) + .with(/ON CONFLICT DO NOTHING/) + + model + .legacy_bulk_insert('test', [{ number: 10 }], on_conflict: :do_nothing) + end + end + end +end +# rubocop: enable Gitlab/BulkInsert diff --git a/spec/models/concerns/loaded_in_group_list_spec.rb b/spec/models/concerns/loaded_in_group_list_spec.rb index c37943022ba..d38e842c666 100644 --- a/spec/models/concerns/loaded_in_group_list_spec.rb +++ b/spec/models/concerns/loaded_in_group_list_spec.rb @@ -3,49 +3,67 @@ require 'spec_helper' RSpec.describe LoadedInGroupList do - let(:parent) { create(:group) } + let_it_be(:parent) { create(:group) } + let_it_be(:group) { create(:group, parent: parent) } + let_it_be(:project) { create(:project, namespace: parent) } - subject(:found_group) { Group.with_selects_for_list.find_by(id: parent.id) } + let(:archived_parameter) { nil } - describe '.with_selects_for_list' do - it 'includes the preloaded counts for groups' do - create(:group, parent: parent) - create(:project, namespace: parent) - parent.add_developer(create(:user)) + before do + parent.add_developer(create(:user)) + end - found_group = Group.with_selects_for_list.find_by(id: parent.id) + subject(:found_group) { Group.with_selects_for_list(archived: archived_parameter).find_by(id: parent.id) } + describe '.with_selects_for_list' do + it 'includes the preloaded counts for groups' do expect(found_group.preloaded_project_count).to eq(1) expect(found_group.preloaded_subgroup_count).to eq(1) expect(found_group.preloaded_member_count).to eq(1) end + context 'with project namespaces' do + let_it_be(:group1) { create(:group, parent: parent) } + let_it_be(:group2) { create(:group, parent: parent) } + let_it_be(:project_namespace) { project.project_namespace } + + it 'does not include project_namespaces in the count of subgroups' do + expect(found_group.preloaded_subgroup_count).to eq(3) + expect(parent.subgroup_count).to eq(3) + end + end + context 'with archived projects' do - it 'counts including archived projects when `true` is passed' do - create(:project, namespace: parent, archived: true) - create(:project, namespace: parent) + let_it_be(:archived_project) { create(:project, namespace: parent, archived: true) } - found_group = Group.with_selects_for_list(archived: 'true').find_by(id: parent.id) + let(:archived_parameter) { true } + it 'counts including archived projects when `true` is passed' do expect(found_group.preloaded_project_count).to eq(2) end - it 'counts only archived projects when `only` is passed' do - create_list(:project, 2, namespace: parent, archived: true) - create(:project, namespace: parent) + context 'when not counting archived projects' do + let(:archived_parameter) { false } + + it 'counts projects without archived ones' do + expect(found_group.preloaded_project_count).to eq(1) + end + end + + context 'with archived only' do + let_it_be(:archived_project2) { create(:project, namespace: parent, archived: true) } - found_group = Group.with_selects_for_list(archived: 'only').find_by(id: parent.id) + let(:archived_parameter) { 'only' } - expect(found_group.preloaded_project_count).to eq(2) + it 'counts only archived projects when `only` is passed' do + expect(found_group.preloaded_project_count).to eq(2) + end end end end describe '#children_count' do it 'counts groups and projects' do - create(:group, parent: parent) - create(:project, namespace: parent) - expect(found_group.children_count).to eq(2) end end diff --git a/spec/models/concerns/loose_foreign_key_spec.rb b/spec/models/concerns/loose_foreign_key_spec.rb index ce5e33261a9..42da69eb75e 100644 --- a/spec/models/concerns/loose_foreign_key_spec.rb +++ b/spec/models/concerns/loose_foreign_key_spec.rb @@ -9,8 +9,8 @@ RSpec.describe LooseForeignKey do self.table_name = 'projects' - loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main - loose_foreign_key 'merge_requests', 'project_id', 'on_delete' => 'async_nullify', 'gitlab_schema' => :gitlab_main + loose_foreign_key :issues, :project_id, on_delete: :async_delete + loose_foreign_key 'merge_requests', 'project_id', 'on_delete' => 'async_nullify' end end @@ -28,7 +28,6 @@ RSpec.describe LooseForeignKey do expect(definition.to_table).to eq('merge_requests') expect(definition.column).to eq('project_id') expect(definition.on_delete).to eq(:async_nullify) - expect(definition.options[:gitlab_schema]).to eq(:gitlab_main) end context 'validation' do @@ -39,9 +38,9 @@ RSpec.describe LooseForeignKey do self.table_name = 'projects' - loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main - loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify, gitlab_schema: :gitlab_main - loose_foreign_key :merge_requests, :project_id, on_delete: :destroy, gitlab_schema: :gitlab_main + loose_foreign_key :issues, :project_id, on_delete: :async_delete + loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify + loose_foreign_key :merge_requests, :project_id, on_delete: :destroy end end @@ -50,28 +49,12 @@ RSpec.describe LooseForeignKey do end end - context 'gitlab_schema validation' do - let(:invalid_class) do - Class.new(ApplicationRecord) do - include LooseForeignKey - - self.table_name = 'projects' - - loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify, gitlab_schema: :unknown - end - end - - it 'raises error when invalid `gitlab_schema` option was given' do - expect { invalid_class }.to raise_error /Invalid gitlab_schema option given: unknown/ - end - end - context 'inheritance validation' do let(:inherited_project_class) do Class.new(Project) do include LooseForeignKey - loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main + loose_foreign_key :issues, :project_id, on_delete: :async_delete end end diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb index 38766d8decd..81ae30b7116 100644 --- a/spec/models/concerns/noteable_spec.rb +++ b/spec/models/concerns/noteable_spec.rb @@ -77,6 +77,70 @@ RSpec.describe Noteable do end end + describe '#discussion_root_note_ids' do + let!(:label_event) { create(:resource_label_event, merge_request: subject) } + let!(:system_note) { create(:system_note, project: project, noteable: subject) } + let!(:milestone_event) { create(:resource_milestone_event, merge_request: subject) } + let!(:state_event) { create(:resource_state_event, merge_request: subject) } + + it 'returns ordered discussion_ids and synthetic note ids' do + discussions = subject.discussion_root_note_ids(notes_filter: UserPreference::NOTES_FILTERS[:all_notes]).map do |n| + { table_name: n.table_name, discussion_id: n.discussion_id, id: n.id } + end + + expect(discussions).to match([ + a_hash_including(table_name: 'notes', discussion_id: active_diff_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: active_diff_note3.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: outdated_diff_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: discussion_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_diff_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_note2.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_discussion_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_discussion_note3.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: note2.discussion_id), + a_hash_including(table_name: 'resource_label_events', id: label_event.id), + a_hash_including(table_name: 'notes', discussion_id: system_note.discussion_id), + a_hash_including(table_name: 'resource_milestone_events', id: milestone_event.id), + a_hash_including(table_name: 'resource_state_events', id: state_event.id) + ]) + end + + it 'filters by comments only' do + discussions = subject.discussion_root_note_ids(notes_filter: UserPreference::NOTES_FILTERS[:only_comments]).map do |n| + { table_name: n.table_name, discussion_id: n.discussion_id, id: n.id } + end + + expect(discussions).to match([ + a_hash_including(table_name: 'notes', discussion_id: active_diff_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: active_diff_note3.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: outdated_diff_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: discussion_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_diff_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_note2.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_discussion_note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: commit_discussion_note3.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: note1.discussion_id), + a_hash_including(table_name: 'notes', discussion_id: note2.discussion_id) + ]) + end + + it 'filters by system notes only' do + discussions = subject.discussion_root_note_ids(notes_filter: UserPreference::NOTES_FILTERS[:only_activity]).map do |n| + { table_name: n.table_name, discussion_id: n.discussion_id, id: n.id } + end + + expect(discussions).to match([ + a_hash_including(table_name: 'resource_label_events', id: label_event.id), + a_hash_including(table_name: 'notes', discussion_id: system_note.discussion_id), + a_hash_including(table_name: 'resource_milestone_events', id: milestone_event.id), + a_hash_including(table_name: 'resource_state_events', id: state_event.id) + ]) + end + end + describe '#grouped_diff_discussions' do let(:grouped_diff_discussions) { subject.grouped_diff_discussions } diff --git a/spec/models/concerns/prometheus_adapter_spec.rb b/spec/models/concerns/prometheus_adapter_spec.rb index 01c987a1d92..4158e8a0a4c 100644 --- a/spec/models/concerns/prometheus_adapter_spec.rb +++ b/spec/models/concerns/prometheus_adapter_spec.rb @@ -165,6 +165,14 @@ RSpec.describe PrometheusAdapter, :use_clean_rails_memory_store_caching do it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) } end end + + context "when client raises Gitlab::PrometheusClient::ConnectionError" do + before do + stub_any_prometheus_request.to_raise(Gitlab::PrometheusClient::ConnectionError) + end + + it { is_expected.to include(success: false, result: kind_of(String)) } + end end describe '#build_query_args' do diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb index 7e031bdd263..4f3b95e43cd 100644 --- a/spec/models/concerns/reactive_caching_spec.rb +++ b/spec/models/concerns/reactive_caching_spec.rb @@ -375,7 +375,7 @@ RSpec.describe ReactiveCaching, :use_clean_rails_memory_store_caching do end describe 'classes including this concern' do - it 'sets reactive_cache_work_type' do + it 'sets reactive_cache_work_type', :eager_load do classes = ObjectSpace.each_object(Class).select do |klass| klass < described_class && klass.name end diff --git a/spec/models/concerns/sha256_attribute_spec.rb b/spec/models/concerns/sha256_attribute_spec.rb index c247865d77f..02947325bf4 100644 --- a/spec/models/concerns/sha256_attribute_spec.rb +++ b/spec/models/concerns/sha256_attribute_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Sha256Attribute do - let(:model) { Class.new { include Sha256Attribute } } + let(:model) { Class.new(ApplicationRecord) { include Sha256Attribute } } before do columns = [ diff --git a/spec/models/concerns/sha_attribute_spec.rb b/spec/models/concerns/sha_attribute_spec.rb index 3846dd9c231..220eadfab92 100644 --- a/spec/models/concerns/sha_attribute_spec.rb +++ b/spec/models/concerns/sha_attribute_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe ShaAttribute do - let(:model) { Class.new { include ShaAttribute } } + let(:model) { Class.new(ApplicationRecord) { include ShaAttribute } } before do columns = [ diff --git a/spec/models/concerns/where_composite_spec.rb b/spec/models/concerns/where_composite_spec.rb index 5e67f2f5b65..6abdd12aac5 100644 --- a/spec/models/concerns/where_composite_spec.rb +++ b/spec/models/concerns/where_composite_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe WhereComposite do describe '.where_composite' do - let_it_be(:test_table_name) { "test_table_#{SecureRandom.hex(10)}" } + let_it_be(:test_table_name) { "_test_table_#{SecureRandom.hex(10)}" } let(:model) do tbl_name = test_table_name diff --git a/spec/models/concerns/x509_serial_number_attribute_spec.rb b/spec/models/concerns/x509_serial_number_attribute_spec.rb index 88550823748..723e2ad07b6 100644 --- a/spec/models/concerns/x509_serial_number_attribute_spec.rb +++ b/spec/models/concerns/x509_serial_number_attribute_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe X509SerialNumberAttribute do - let(:model) { Class.new { include X509SerialNumberAttribute } } + let(:model) { Class.new(ApplicationRecord) { include X509SerialNumberAttribute } } before do columns = [ diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb index 4a8b671bab7..01252a58681 100644 --- a/spec/models/custom_emoji_spec.rb +++ b/spec/models/custom_emoji_spec.rb @@ -14,7 +14,7 @@ RSpec.describe CustomEmoji do end describe 'exclusion of duplicated emoji' do - let(:emoji_name) { Gitlab::Emoji.emojis_names.sample } + let(:emoji_name) { TanukiEmoji.index.all.sample.name } let(:group) { create(:group, :private) } it 'disallows emoji names of built-in emoji' do diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb index 298d5db3ab9..3a2d4e2d0ca 100644 --- a/spec/models/customer_relations/contact_spec.rb +++ b/spec/models/customer_relations/contact_spec.rb @@ -6,7 +6,8 @@ RSpec.describe CustomerRelations::Contact, type: :model do describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:organization).optional } - it { is_expected.to have_and_belong_to_many(:issues) } + it { is_expected.to have_many(:issue_contacts) } + it { is_expected.to have_many(:issues) } end describe 'validations' do diff --git a/spec/models/customer_relations/issue_contact_spec.rb b/spec/models/customer_relations/issue_contact_spec.rb new file mode 100644 index 00000000000..3747d159833 --- /dev/null +++ b/spec/models/customer_relations/issue_contact_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CustomerRelations::IssueContact do + let_it_be(:issue_contact, reload: true) { create(:issue_customer_relations_contact) } + + subject { issue_contact } + + it { expect(subject).to be_valid } + + describe 'associations' do + it { is_expected.to belong_to(:issue).required } + it { is_expected.to belong_to(:contact).required } + end + + describe 'factory' do + let(:built) { build(:issue_customer_relations_contact) } + let(:stubbed) { build_stubbed(:issue_customer_relations_contact) } + let(:created) { create(:issue_customer_relations_contact) } + + let(:group) { build(:group) } + let(:project) { build(:project, group: group) } + let(:issue) { build(:issue, project: project) } + let(:contact) { build(:contact, group: group) } + let(:for_issue) { build(:issue_customer_relations_contact, :for_issue, issue: issue) } + let(:for_contact) { build(:issue_customer_relations_contact, :for_contact, contact: contact) } + + it 'uses objects from the same group', :aggregate_failures do + expect(stubbed.contact.group).to eq(stubbed.issue.project.group) + expect(built.contact.group).to eq(built.issue.project.group) + expect(created.contact.group).to eq(created.issue.project.group) + end + + it 'builds using the same group', :aggregate_failures do + expect(for_issue.contact.group).to eq(group) + expect(for_contact.issue.project.group).to eq(group) + end + end + + describe 'validation' do + let(:built) { build(:issue_customer_relations_contact, issue: create(:issue), contact: create(:contact)) } + + it 'fails when the contact group does not match the issue group' do + expect(built).not_to be_valid + end + end +end diff --git a/spec/models/data_list_spec.rb b/spec/models/data_list_spec.rb new file mode 100644 index 00000000000..d2f15386808 --- /dev/null +++ b/spec/models/data_list_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe DataList do + describe '#to_array' do + let(:jira_integration) { create(:jira_integration) } + let(:zentao_integration) { create(:zentao_integration) } + let(:cases) do + [ + [jira_integration, 'Integrations::JiraTrackerData', 'service_id'], + [zentao_integration, 'Integrations::ZentaoTrackerData', 'integration_id'] + ] + end + + def data_list(integration) + DataList.new([integration], integration.to_data_fields_hash, integration.data_fields.class).to_array + end + + it 'returns current data' do + cases.each do |integration, data_fields_class_name, foreign_key| + data_fields_klass, columns, values_items = data_list(integration) + + expect(data_fields_klass.to_s).to eq data_fields_class_name + expect(columns.last).to eq foreign_key + values = values_items.first + expect(values.last).to eq integration.id + end + end + end +end diff --git a/spec/models/dependency_proxy/manifest_spec.rb b/spec/models/dependency_proxy/manifest_spec.rb index e7f0889345a..59415096989 100644 --- a/spec/models/dependency_proxy/manifest_spec.rb +++ b/spec/models/dependency_proxy/manifest_spec.rb @@ -15,6 +15,17 @@ RSpec.describe DependencyProxy::Manifest, type: :model do it { is_expected.to validate_presence_of(:digest) } end + describe 'scopes' do + let_it_be(:manifest_one) { create(:dependency_proxy_manifest) } + let_it_be(:manifest_two) { create(:dependency_proxy_manifest) } + let_it_be(:manifests) { [manifest_one, manifest_two] } + let_it_be(:ids) { manifests.map(&:id) } + + it 'order_id_desc' do + expect(described_class.where(id: ids).order_id_desc.to_a).to eq [manifest_two, manifest_one] + end + end + describe 'file is being stored' do subject { create(:dependency_proxy_manifest) } @@ -31,18 +42,14 @@ RSpec.describe DependencyProxy::Manifest, type: :model do end end - describe '.find_or_initialize_by_file_name_or_digest' do + describe '.find_by_file_name_or_digest' do let_it_be(:file_name) { 'foo' } let_it_be(:digest) { 'bar' } - subject { DependencyProxy::Manifest.find_or_initialize_by_file_name_or_digest(file_name: file_name, digest: digest) } + subject { DependencyProxy::Manifest.find_by_file_name_or_digest(file_name: file_name, digest: digest) } context 'no manifest exists' do - it 'initializes a manifest' do - expect(DependencyProxy::Manifest).to receive(:new).with(file_name: file_name, digest: digest) - - subject - end + it { is_expected.to be_nil } end context 'manifest exists and matches file_name' do diff --git a/spec/models/deploy_key_spec.rb b/spec/models/deploy_key_spec.rb index fa78527e366..c22bad0e062 100644 --- a/spec/models/deploy_key_spec.rb +++ b/spec/models/deploy_key_spec.rb @@ -5,6 +5,17 @@ require 'spec_helper' RSpec.describe DeployKey, :mailer do describe "Associations" do it { is_expected.to have_many(:deploy_keys_projects) } + it do + is_expected.to have_many(:deploy_keys_projects_with_write_access) + .conditions(can_push: true) + .class_name('DeployKeysProject') + end + it do + is_expected.to have_many(:projects_with_write_access) + .class_name('Project') + .through(:deploy_keys_projects_with_write_access) + .source(:project) + end it { is_expected.to have_many(:projects) } it { is_expected.to have_many(:protected_branch_push_access_levels) } end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index f9a05fbb06f..51e1e63da8d 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -385,6 +385,43 @@ RSpec.describe Deployment do end end + describe '.archivables_in' do + subject { described_class.archivables_in(project, limit: limit) } + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:deployment_1) { create(:deployment, project: project) } + let_it_be(:deployment_2) { create(:deployment, project: project) } + let_it_be(:deployment_3) { create(:deployment, project: project) } + + let(:limit) { 100 } + + context 'when there are no archivable deployments in the project' do + it 'returns nothing' do + expect(subject).to be_empty + end + end + + context 'when there are archivable deployments in the project' do + before do + stub_const("::Deployment::ARCHIVABLE_OFFSET", 1) + end + + it 'returns all archivable deployments' do + expect(subject.count).to eq(2) + expect(subject).to contain_exactly(deployment_1, deployment_2) + end + + context 'with limit' do + let(:limit) { 1 } + + it 'takes the limit into account' do + expect(subject.count).to eq(1) + expect(subject.take).to be_in([deployment_1, deployment_2]) + end + end + end + end + describe 'scopes' do describe 'last_for_environment' do let(:production) { create(:environment) } @@ -456,6 +493,17 @@ RSpec.describe Deployment do end end + describe '.ordered' do + let!(:deployment1) { create(:deployment, status: :running) } + let!(:deployment2) { create(:deployment, status: :success, finished_at: Time.current) } + let!(:deployment3) { create(:deployment, status: :canceled, finished_at: 1.day.ago) } + let!(:deployment4) { create(:deployment, status: :success, finished_at: 2.days.ago) } + + it 'sorts by finished at' do + expect(described_class.ordered).to eq([deployment1, deployment2, deployment3, deployment4]) + end + end + describe 'visible' do subject { described_class.visible } @@ -763,6 +811,7 @@ RSpec.describe Deployment do it 'schedules workers when finishing a deploy' do expect(Deployments::UpdateEnvironmentWorker).to receive(:perform_async) expect(Deployments::LinkMergeRequestWorker).to receive(:perform_async) + expect(Deployments::ArchiveInProjectWorker).to receive(:perform_async) expect(Deployments::HooksWorker).to receive(:perform_async) expect(deploy.update_status('success')).to eq(true) @@ -840,6 +889,12 @@ RSpec.describe Deployment do context 'with created deployment' do let(:deployment_status) { :created } + context 'with created build' do + let(:build_status) { :created } + + it_behaves_like 'ignoring build' + end + context 'with running build' do let(:build_status) { :running } @@ -862,12 +917,16 @@ RSpec.describe Deployment do context 'with running deployment' do let(:deployment_status) { :running } + context 'with created build' do + let(:build_status) { :created } + + it_behaves_like 'ignoring build' + end + context 'with running build' do let(:build_status) { :running } - it_behaves_like 'gracefully handling error' do - let(:error_message) { %Q{Status cannot transition via \"run\"} } - end + it_behaves_like 'ignoring build' end context 'with finished build' do @@ -886,6 +945,12 @@ RSpec.describe Deployment do context 'with finished deployment' do let(:deployment_status) { :success } + context 'with created build' do + let(:build_status) { :created } + + it_behaves_like 'ignoring build' + end + context 'with running build' do let(:build_status) { :running } @@ -897,9 +962,13 @@ RSpec.describe Deployment do context 'with finished build' do let(:build_status) { :success } - it_behaves_like 'gracefully handling error' do - let(:error_message) { %Q{Status cannot transition via \"succeed\"} } - end + it_behaves_like 'ignoring build' + end + + context 'with failed build' do + let(:build_status) { :failed } + + it_behaves_like 'synchronizing deployment' end context 'with unrelated build' do diff --git a/spec/models/design_management/version_spec.rb b/spec/models/design_management/version_spec.rb index e004ad024bc..303bac61e1e 100644 --- a/spec/models/design_management/version_spec.rb +++ b/spec/models/design_management/version_spec.rb @@ -283,7 +283,7 @@ RSpec.describe DesignManagement::Version do it 'retrieves author from the Commit if author_id is nil and version has been persisted' do author = create(:user) version = create(:design_version, :committed, author: author) - author.destroy + author.destroy! version.reload commit = version.issue.project.design_repository.commit(version.sha) commit_user = create(:user, email: commit.author_email, name: commit.author_name) diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb index 2b09ee5c190..59299a507e4 100644 --- a/spec/models/email_spec.rb +++ b/spec/models/email_spec.rb @@ -13,6 +13,15 @@ RSpec.describe Email do it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email do subject { build(:email) } end + + context 'when the email conflicts with the primary email of a different user' do + let(:user) { create(:user) } + let(:email) { build(:email, email: user.email) } + + it 'is invalid' do + expect(email).to be_invalid + end + end end it 'normalize email value' do @@ -33,7 +42,7 @@ RSpec.describe Email do end describe 'scopes' do - let(:user) { create(:user) } + let(:user) { create(:user, :unconfirmed) } it 'scopes confirmed emails' do create(:email, :confirmed, user: user) diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 08c639957d3..9d9862aa3d3 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -39,7 +39,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do it 'ensures environment tier when a new object is created' do environment = build(:environment, name: 'gprd', tier: nil) - expect { environment.save }.to change { environment.tier }.from(nil).to('production') + expect { environment.save! }.to change { environment.tier }.from(nil).to('production') end it 'ensures environment tier when an existing object is updated' do @@ -418,7 +418,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do context 'not in the same branch' do before do - deployment.update(sha: project.commit('feature').id) + deployment.update!(sha: project.commit('feature').id) end it 'returns false' do @@ -496,7 +496,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do context 'when no other actions' do context 'environment is available' do before do - environment.update(state: :available) + environment.update!(state: :available) end it do @@ -508,7 +508,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do context 'environment is already stopped' do before do - environment.update(state: :stopped) + environment.update!(state: :stopped) end it do @@ -1502,7 +1502,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do deployment = create(:deployment, :success, environment: environment, project: project) deployment.create_ref - expect { environment.destroy }.to change { project.commit(deployment.ref_path) }.to(nil) + expect { environment.destroy! }.to change { project.commit(deployment.ref_path) }.to(nil) end end @@ -1517,7 +1517,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end it 'returns the environments count grouped by state with zero value' do - environment2.update(state: 'stopped') + environment2.update!(state: 'stopped') expect(project.environments.count_by_state).to eq({ stopped: 3, available: 0 }) end end @@ -1710,4 +1710,36 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do subject end end + + describe '#should_link_to_merge_requests?' do + subject { environment.should_link_to_merge_requests? } + + context 'when environment is foldered' do + context 'when environment is production tier' do + let(:environment) { create(:environment, project: project, name: 'production/aws') } + + it { is_expected.to eq(true) } + end + + context 'when environment is development tier' do + let(:environment) { create(:environment, project: project, name: 'review/feature') } + + it { is_expected.to eq(false) } + end + end + + context 'when environment is unfoldered' do + context 'when environment is production tier' do + let(:environment) { create(:environment, project: project, name: 'production') } + + it { is_expected.to eq(true) } + end + + context 'when environment is development tier' do + let(:environment) { create(:environment, project: project, name: 'development') } + + it { is_expected.to eq(true) } + end + end + end end diff --git a/spec/models/error_tracking/error_event_spec.rb b/spec/models/error_tracking/error_event_spec.rb index 8e20eb25353..9cf5a405e74 100644 --- a/spec/models/error_tracking/error_event_spec.rb +++ b/spec/models/error_tracking/error_event_spec.rb @@ -11,7 +11,10 @@ RSpec.describe ErrorTracking::ErrorEvent, type: :model do describe 'validations' do it { is_expected.to validate_presence_of(:description) } + it { is_expected.to validate_length_of(:description).is_at_most(1024) } it { is_expected.to validate_presence_of(:occurred_at) } + it { is_expected.to validate_length_of(:level).is_at_most(255) } + it { is_expected.to validate_length_of(:environment).is_at_most(255) } end describe '#stacktrace' do @@ -37,6 +40,23 @@ RSpec.describe ErrorTracking::ErrorEvent, type: :model do expect(event.stacktrace).to be_kind_of(Array) expect(event.stacktrace.first).to eq(expected_entry) end + + context 'error context is missing' do + let(:event) { create(:error_tracking_error_event, :browser) } + + it 'generates a stacktrace without context' do + expected_entry = { + 'lineNo' => 6395, + 'context' => [], + 'filename' => 'webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js', + 'function' => 'hydrate', + 'colNo' => 0 + } + + expect(event.stacktrace).to be_kind_of(Array) + expect(event.stacktrace.first).to eq(expected_entry) + end + end end describe '#to_sentry_error_event' do diff --git a/spec/models/error_tracking/error_spec.rb b/spec/models/error_tracking/error_spec.rb index 9b8a81c6372..363cd197f3e 100644 --- a/spec/models/error_tracking/error_spec.rb +++ b/spec/models/error_tracking/error_spec.rb @@ -12,8 +12,12 @@ RSpec.describe ErrorTracking::Error, type: :model do describe 'validations' do it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_presence_of(:description) } + it { is_expected.to validate_length_of(:description).is_at_most(1024) } it { is_expected.to validate_presence_of(:actor) } + it { is_expected.to validate_length_of(:actor).is_at_most(255) } + it { is_expected.to validate_length_of(:platform).is_at_most(255) } end describe '.report_error' do diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 41510b7aa1c..ee27eaf1d0b 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Event do describe 'after_create :set_last_repository_updated_at' do context 'with a push event' do it 'updates the project last_repository_updated_at' do - project.update(last_repository_updated_at: 1.year.ago) + project.update!(last_repository_updated_at: 1.year.ago) create_push_event(project, project.owner) @@ -44,7 +44,7 @@ RSpec.describe Event do context 'without a push event' do it 'does not update the project last_repository_updated_at' do - project.update(last_repository_updated_at: 1.year.ago) + project.update!(last_repository_updated_at: 1.year.ago) create(:closed_issue_event, project: project, author: project.owner) @@ -58,7 +58,7 @@ RSpec.describe Event do describe '#set_last_repository_updated_at' do it 'only updates once every Event::REPOSITORY_UPDATED_AT_INTERVAL minutes' do last_known_timestamp = (Event::REPOSITORY_UPDATED_AT_INTERVAL - 1.minute).ago - project.update(last_repository_updated_at: last_known_timestamp) + project.update!(last_repository_updated_at: last_known_timestamp) project.reload # a reload removes fractions of seconds expect do @@ -73,7 +73,7 @@ RSpec.describe Event do it 'passes event to UserInteractedProject.track' do expect(UserInteractedProject).to receive(:track).with(event) - event.save + event.save! end end end @@ -824,7 +824,7 @@ RSpec.describe Event do context 'when a project was updated less than 1 hour ago' do it 'does not update the project' do - project.update(last_activity_at: Time.current) + project.update!(last_activity_at: Time.current) expect(project).not_to receive(:update_column) .with(:last_activity_at, a_kind_of(Time)) @@ -835,7 +835,7 @@ RSpec.describe Event do context 'when a project was updated more than 1 hour ago' do it 'updates the project' do - project.update(last_activity_at: 1.year.ago) + project.update!(last_activity_at: 1.year.ago) create_push_event(project, project.owner) diff --git a/spec/models/fork_network_spec.rb b/spec/models/fork_network_spec.rb index c2ef1fdcb5f..f2ec0ccb4fd 100644 --- a/spec/models/fork_network_spec.rb +++ b/spec/models/fork_network_spec.rb @@ -8,7 +8,7 @@ RSpec.describe ForkNetwork do describe '#add_root_as_member' do it 'adds the root project as a member when creating a new root network' do project = create(:project) - fork_network = described_class.create(root_project: project) + fork_network = described_class.create!(root_project: project) expect(fork_network.projects).to include(project) end @@ -54,8 +54,8 @@ RSpec.describe ForkNetwork do first_fork = fork_project(first_project) second_fork = fork_project(second_project) - first_project.destroy - second_project.destroy + first_project.destroy! + second_project.destroy! expect(first_fork.fork_network).not_to be_nil expect(first_fork.fork_network.root_project).to be_nil diff --git a/spec/models/generic_commit_status_spec.rb b/spec/models/generic_commit_status_spec.rb index 6fe5a1407a9..9d70019734b 100644 --- a/spec/models/generic_commit_status_spec.rb +++ b/spec/models/generic_commit_status_spec.rb @@ -133,7 +133,7 @@ RSpec.describe GenericCommitStatus do before do generic_commit_status.context = nil generic_commit_status.stage = nil - generic_commit_status.save + generic_commit_status.save! end describe '#context' do diff --git a/spec/models/grafana_integration_spec.rb b/spec/models/grafana_integration_spec.rb index 79f102919ac..bb822187e0c 100644 --- a/spec/models/grafana_integration_spec.rb +++ b/spec/models/grafana_integration_spec.rb @@ -60,7 +60,7 @@ RSpec.describe GrafanaIntegration do context 'with grafana integration enabled' do it 'returns nil' do - grafana_integration.update(enabled: false) + grafana_integration.update!(enabled: false) expect(grafana_integration.client).to be(nil) end @@ -81,8 +81,8 @@ RSpec.describe GrafanaIntegration do end it 'prevents overriding token value with its encrypted or masked version', :aggregate_failures do - expect { grafana_integration.update(token: grafana_integration.encrypted_token) }.not_to change { grafana_integration.reload.send(:token) } - expect { grafana_integration.update(token: grafana_integration.masked_token) }.not_to change { grafana_integration.reload.send(:token) } + expect { grafana_integration.update!(token: grafana_integration.encrypted_token) }.not_to change { grafana_integration.reload.send(:token) } + expect { grafana_integration.update!(token: grafana_integration.masked_token) }.not_to change { grafana_integration.reload.send(:token) } end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index e88abc21ef2..735aa4df2ba 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -37,6 +37,8 @@ RSpec.describe Group do it { is_expected.to have_many(:daily_build_group_report_results).class_name('Ci::DailyBuildGroupReportResult') } it { is_expected.to have_many(:group_callouts).class_name('Users::GroupCallout').with_foreign_key(:group_id) } it { is_expected.to have_many(:bulk_import_exports).class_name('BulkImports::Export') } + it { is_expected.to have_many(:contacts).class_name('CustomerRelations::Contact') } + it { is_expected.to have_many(:organizations).class_name('CustomerRelations::Organization') } describe '#members & #requesters' do let(:requester) { create(:user) } @@ -160,7 +162,7 @@ RSpec.describe Group do context 'when sub group is deleted' do it 'does not delete parent notification settings' do expect do - sub_group.destroy + sub_group.destroy! end.to change { NotificationSetting.count }.by(-1) end end @@ -404,7 +406,7 @@ RSpec.describe Group do subject do recorded_queries.record do - group.update(parent: new_parent) + group.update!(parent: new_parent) end end @@ -496,7 +498,7 @@ RSpec.describe Group do let!(:group) { create(:group, parent: parent_group) } before do - parent_group.update(parent: new_grandparent) + parent_group.update!(parent: new_grandparent) end it 'updates traversal_ids for all descendants' do @@ -563,6 +565,15 @@ RSpec.describe Group do it { expect(group.ancestors.to_sql).not_to include 'traversal_ids <@' } end end + + context 'when project namespace exists in the group' do + let!(:project) { create(:project, group: group) } + let!(:project_namespace) { project.project_namespace } + + it 'filters out project namespace' do + expect(group.descendants.find_by_id(project_namespace.id)).to be_nil + end + end end end @@ -571,8 +582,8 @@ RSpec.describe Group do let(:instance_integration) { build(:jira_integration, :instance) } before do - create(:jira_integration, group: group, project: nil) - create(:integrations_slack, group: another_group, project: nil) + create(:jira_integration, :group, group: group) + create(:integrations_slack, :group, group: another_group) end it 'returns groups without integration' do @@ -718,6 +729,22 @@ RSpec.describe Group do expect(group.group_members.developers.map(&:user)).to include(user) expect(group.group_members.guests.map(&:user)).not_to include(user) end + + context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do + let!(:project) { create(:project, group: group) } + + before do + stub_experiments(invite_members_for_task: true) + group.add_users([create(:user)], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id) + end + + it 'creates a member_task with the correct attributes', :aggregate_failures do + member = group.group_members.last + + expect(member.tasks_to_be_done).to match_array([:ci, :code]) + expect(member.member_task.project).to eq(project) + end + end end describe '#avatar_type' do @@ -831,7 +858,7 @@ RSpec.describe Group do before do parent_group = create(:group) create(:group_member, :owner, group: parent_group) - group.update(parent: parent_group) + group.update!(parent: parent_group) end it { expect(group.last_owner?(@members[:owner])).to be_falsy } @@ -888,7 +915,7 @@ RSpec.describe Group do before do parent_group = create(:group) create(:group_member, :owner, group: parent_group) - group.update(parent: parent_group) + group.update!(parent: parent_group) end it { expect(group.member_last_blocked_owner?(member)).to be(false) } @@ -1936,7 +1963,7 @@ RSpec.describe Group do let(:environment) { 'foo%bar/test' } it 'matches literally for %' do - ci_variable.update(environment_scope: 'foo%bar/*') + ci_variable.update_attribute(:environment_scope, 'foo%bar/*') is_expected.to contain_exactly(ci_variable) end @@ -2077,7 +2104,7 @@ RSpec.describe Group do let(:ancestor_group) { create(:group) } before do - group.update(parent: ancestor_group) + group.update!(parent: ancestor_group) end it 'returns all ancestor group ids' do @@ -2594,7 +2621,7 @@ RSpec.describe Group do let_it_be(:project) { create(:project, group: group, service_desk_enabled: false) } before do - project.update(service_desk_enabled: false) + project.update!(service_desk_enabled: false) end it { is_expected.to eq(false) } @@ -2623,14 +2650,6 @@ RSpec.describe Group do end it_behaves_like 'returns namespaces with disabled email' - - context 'when feature flag :linear_group_ancestor_scopes is disabled' do - before do - stub_feature_flags(linear_group_ancestor_scopes: false) - end - - it_behaves_like 'returns namespaces with disabled email' - end end describe '.timelogs' do diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb index d811f67d16b..f0ee9a613d8 100644 --- a/spec/models/hooks/project_hook_spec.rb +++ b/spec/models/hooks/project_hook_spec.rb @@ -32,8 +32,8 @@ RSpec.describe ProjectHook do end describe '#rate_limit' do - let_it_be(:hook) { create(:project_hook) } let_it_be(:plan_limits) { create(:plan_limits, :default_plan, web_hook_calls: 100) } + let_it_be(:hook) { create(:project_hook) } it 'returns the default limit' do expect(hook.rate_limit).to be(100) diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb index c0efb2dff56..124c54a2028 100644 --- a/spec/models/identity_spec.rb +++ b/spec/models/identity_spec.rb @@ -118,19 +118,19 @@ RSpec.describe Identity do it 'if extern_uid changes' do expect(ldap_identity).not_to receive(:ensure_normalized_extern_uid) - ldap_identity.save + ldap_identity.save! end it 'if current_uid is nil' do expect(ldap_identity).to receive(:ensure_normalized_extern_uid) - ldap_identity.update(extern_uid: nil) + ldap_identity.update!(extern_uid: nil) expect(ldap_identity.extern_uid).to be_nil end it 'if extern_uid changed and not nil' do - ldap_identity.update(extern_uid: 'uid=john1,ou=PEOPLE,dc=example,dc=com') + ldap_identity.update!(extern_uid: 'uid=john1,ou=PEOPLE,dc=example,dc=com') expect(ldap_identity.extern_uid).to eq 'uid=john1,ou=people,dc=example,dc=com' end @@ -150,7 +150,7 @@ RSpec.describe Identity do expect(user.user_synced_attributes_metadata.provider).to eq 'ldapmain' - ldap_identity.destroy + ldap_identity.destroy! expect(user.reload.user_synced_attributes_metadata).to be_nil end @@ -162,7 +162,7 @@ RSpec.describe Identity do expect(user.user_synced_attributes_metadata.provider).to eq 'other' - ldap_identity.destroy + ldap_identity.destroy! expect(user.reload.user_synced_attributes_metadata.provider).to eq 'other' end diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index 1a83d948fcf..de47fb3839a 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -299,7 +299,7 @@ RSpec.describe Integration do end context 'when integration is a group-level integration' do - let(:group_integration) { create(:jira_integration, group: group, project: nil) } + let(:group_integration) { create(:jira_integration, :group, group: group) } it 'sets inherit_from_id from integration' do integration = described_class.build_from_integration(group_integration, project_id: project.id) @@ -458,7 +458,7 @@ RSpec.describe Integration do end context 'with an active group-level integration' do - let!(:group_integration) { create(:prometheus_integration, group: group, project: nil, api_url: 'https://prometheus.group.com/') } + let!(:group_integration) { create(:prometheus_integration, :group, group: group, api_url: 'https://prometheus.group.com/') } it 'creates an integration from the group-level integration' do described_class.create_from_active_default_integrations(project, :project_id) @@ -481,7 +481,7 @@ RSpec.describe Integration do end context 'with an active subgroup' do - let!(:subgroup_integration) { create(:prometheus_integration, group: subgroup, project: nil, api_url: 'https://prometheus.subgroup.com/') } + let!(:subgroup_integration) { create(:prometheus_integration, :group, group: subgroup, api_url: 'https://prometheus.subgroup.com/') } let!(:subgroup) { create(:group, parent: group) } let(:project) { create(:project, group: subgroup) } @@ -509,7 +509,7 @@ RSpec.describe Integration do end context 'having an integration inheriting settings' do - let!(:subgroup_integration) { create(:prometheus_integration, group: subgroup, project: nil, inherit_from_id: group_integration.id, api_url: 'https://prometheus.subgroup.com/') } + let!(:subgroup_integration) { create(:prometheus_integration, :group, group: subgroup, inherit_from_id: group_integration.id, api_url: 'https://prometheus.subgroup.com/') } it 'creates an integration from the group-level integration' do described_class.create_from_active_default_integrations(sub_subgroup, :group_id) @@ -552,11 +552,11 @@ RSpec.describe Integration do let_it_be(:subgroup2) { create(:group, parent: group) } let_it_be(:project1) { create(:project, group: subgroup1) } let_it_be(:project2) { create(:project, group: subgroup2) } - let_it_be(:group_integration) { create(:prometheus_integration, group: group, project: nil) } - let_it_be(:subgroup_integration1) { create(:prometheus_integration, group: subgroup1, project: nil, inherit_from_id: group_integration.id) } - let_it_be(:subgroup_integration2) { create(:prometheus_integration, group: subgroup2, project: nil) } - let_it_be(:project_integration1) { create(:prometheus_integration, group: nil, project: project1, inherit_from_id: group_integration.id) } - let_it_be(:project_integration2) { create(:prometheus_integration, group: nil, project: project2, inherit_from_id: subgroup_integration2.id) } + let_it_be(:group_integration) { create(:prometheus_integration, :group, group: group) } + let_it_be(:subgroup_integration1) { create(:prometheus_integration, :group, group: subgroup1, inherit_from_id: group_integration.id) } + let_it_be(:subgroup_integration2) { create(:prometheus_integration, :group, group: subgroup2) } + let_it_be(:project_integration1) { create(:prometheus_integration, project: project1, inherit_from_id: group_integration.id) } + let_it_be(:project_integration2) { create(:prometheus_integration, project: project2, inherit_from_id: subgroup_integration2.id) } it 'returns the groups and projects inheriting from integration ancestors', :aggregate_failures do expect(described_class.inherited_descendants_from_self_or_ancestors_from(group_integration)).to eq([subgroup_integration1, project_integration1]) diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb index 0321b151633..1d81668f97d 100644 --- a/spec/models/integrations/jira_spec.rb +++ b/spec/models/integrations/jira_spec.rb @@ -495,6 +495,18 @@ RSpec.describe Integrations::Jira do end end + describe '#client' do + it 'uses the default GitLab::HTTP timeouts' do + timeouts = Gitlab::HTTP::DEFAULT_TIMEOUT_OPTIONS + stub_request(:get, 'http://jira.example.com/foo') + + expect(Gitlab::HTTP).to receive(:httparty_perform_request) + .with(Net::HTTP::Get, '/foo', hash_including(timeouts)).and_call_original + + jira_integration.client.get('/foo') + end + end + describe '#find_issue' do let(:issue_key) { 'JIRA-123' } let(:issue_url) { "#{url}/rest/api/2/issue/#{issue_key}" } @@ -503,7 +515,7 @@ RSpec.describe Integrations::Jira do stub_request(:get, issue_url).with(basic_auth: [username, password]) end - it 'call the Jira API to get the issue' do + it 'calls the Jira API to get the issue' do jira_integration.find_issue(issue_key) expect(WebMock).to have_requested(:get, issue_url) @@ -845,10 +857,14 @@ RSpec.describe Integrations::Jira do let_it_be(:user) { build_stubbed(:user) } let(:jira_issue) { ExternalIssue.new('JIRA-123', project) } + let(:success_message) { 'SUCCESS: Successfully posted to http://jira.example.com.' } + let(:favicon_path) { "http://localhost/assets/#{find_asset('favicon.png').digest_path}" } subject { jira_integration.create_cross_reference_note(jira_issue, resource, user) } - shared_examples 'creates a comment on Jira' do + shared_examples 'handles cross-references' do + let(:resource_name) { jira_integration.send(:noteable_name, resource) } + let(:resource_url) { jira_integration.send(:build_entity_url, resource_name, resource.to_param) } let(:issue_url) { "#{url}/rest/api/2/issue/JIRA-123" } let(:comment_url) { "#{issue_url}/comment" } let(:remote_link_url) { "#{issue_url}/remotelink" } @@ -860,12 +876,77 @@ RSpec.describe Integrations::Jira do stub_request(:post, remote_link_url).with(basic_auth: [username, password]) end - it 'creates a comment on Jira' do - subject + context 'when enabled' do + before do + allow(jira_integration).to receive(:can_cross_reference?) { true } + end - expect(WebMock).to have_requested(:post, comment_url).with( - body: /mentioned this issue in/ - ).once + it 'creates a comment and remote link' do + expect(subject).to eq(success_message) + expect(WebMock).to have_requested(:post, comment_url).with(body: comment_body).once + expect(WebMock).to have_requested(:post, remote_link_url).with( + body: hash_including( + GlobalID: 'GitLab', + relationship: 'mentioned on', + object: { + url: resource_url, + title: "#{resource.model_name.human} - #{resource.title}", + icon: { title: 'GitLab', url16x16: favicon_path }, + status: { resolved: false } + } + ) + ).once + end + + context 'when comment already exists' do + before do + allow(jira_integration).to receive(:comment_exists?) { true } + end + + it 'does not create a comment or remote link' do + expect(subject).to be_nil + expect(WebMock).not_to have_requested(:post, comment_url) + expect(WebMock).not_to have_requested(:post, remote_link_url) + end + end + + context 'when remote link already exists' do + let(:link) { double(object: { 'url' => resource_url }) } + + before do + allow(jira_integration).to receive(:find_remote_link).and_return(link) + end + + it 'updates the remote link but does not create a comment' do + expect(link).to receive(:save!) + expect(subject).to eq(success_message) + expect(WebMock).not_to have_requested(:post, comment_url) + end + end + end + + context 'when disabled' do + before do + allow(jira_integration).to receive(:can_cross_reference?) { false } + end + + it 'does not create a comment or remote link' do + expect(subject).to eq("Events for #{resource_name.pluralize.humanize(capitalize: false)} are disabled.") + expect(WebMock).not_to have_requested(:post, comment_url) + expect(WebMock).not_to have_requested(:post, remote_link_url) + end + end + + context 'with jira_use_first_ref_by_oid feature flag disabled' do + before do + stub_feature_flags(jira_use_first_ref_by_oid: false) + end + + it 'creates a comment and remote link on Jira' do + expect(subject).to eq(success_message) + expect(WebMock).to have_requested(:post, comment_url).with(body: comment_body).once + expect(WebMock).to have_requested(:post, remote_link_url).once + end end it 'tracks usage' do @@ -877,39 +958,38 @@ RSpec.describe Integrations::Jira do end end - context 'when resource is a commit' do - let(:resource) { project.commit('master') } - - context 'when disabled' do - before do - allow_next_instance_of(described_class) do |instance| - allow(instance).to receive(:commit_events) { false } - end - end - - it { is_expected.to eq('Events for commits are disabled.') } + context 'for commits' do + it_behaves_like 'handles cross-references' do + let(:resource) { project.commit('master') } + let(:comment_body) { /mentioned this issue in \[a commit\|.* on branch \[master\|/ } end + end - context 'when enabled' do - it_behaves_like 'creates a comment on Jira' + context 'for issues' do + it_behaves_like 'handles cross-references' do + let(:resource) { build_stubbed(:issue, project: project) } + let(:comment_body) { /mentioned this issue in \[a issue\|/ } end end - context 'when resource is a merge request' do - let(:resource) { build_stubbed(:merge_request, source_project: project) } - - context 'when disabled' do - before do - allow_next_instance_of(described_class) do |instance| - allow(instance).to receive(:merge_requests_events) { false } - end - end + context 'for merge requests' do + it_behaves_like 'handles cross-references' do + let(:resource) { build_stubbed(:merge_request, source_project: project) } + let(:comment_body) { /mentioned this issue in \[a merge request\|.* on branch \[master\|/ } + end + end - it { is_expected.to eq('Events for merge requests are disabled.') } + context 'for notes' do + it_behaves_like 'handles cross-references' do + let(:resource) { build_stubbed(:note, project: project) } + let(:comment_body) { /mentioned this issue in \[a note\|/ } end + end - context 'when enabled' do - it_behaves_like 'creates a comment on Jira' + context 'for snippets' do + it_behaves_like 'handles cross-references' do + let(:resource) { build_stubbed(:snippet, project: project) } + let(:comment_body) { /mentioned this issue in \[a snippet\|/ } end end end @@ -946,7 +1026,9 @@ RSpec.describe Integrations::Jira do expect(jira_integration).to receive(:log_error).with( 'Error sending message', client_url: 'http://jira.example.com', - error: error_message + 'exception.class' => anything, + 'exception.message' => error_message, + 'exception.backtrace' => anything ) expect(jira_integration.test(nil)).to eq(success: false, result: error_message) diff --git a/spec/models/integrations/pipelines_email_spec.rb b/spec/models/integrations/pipelines_email_spec.rb index afd9d71ebc4..d70f104b965 100644 --- a/spec/models/integrations/pipelines_email_spec.rb +++ b/spec/models/integrations/pipelines_email_spec.rb @@ -35,6 +35,42 @@ RSpec.describe Integrations::PipelinesEmail, :mailer do it { is_expected.not_to validate_presence_of(:recipients) } end + + describe 'validates number of recipients' do + before do + stub_const("#{described_class}::RECIPIENTS_LIMIT", 2) + end + + subject(:integration) { described_class.new(project: project, recipients: recipients, active: true) } + + context 'valid number of recipients' do + let(:recipients) { 'foo@bar.com, , ' } + + it 'does not count empty emails' do + is_expected.to be_valid + end + end + + context 'invalid number of recipients' do + let(:recipients) { 'foo@bar.com bar@foo.com bob@gitlab.com' } + + it { is_expected.not_to be_valid } + + it 'adds an error message' do + integration.valid? + + expect(integration.errors).to contain_exactly('Recipients can\'t exceed 2') + end + + context 'when integration is not active' do + before do + integration.active = false + end + + it { is_expected.to be_valid } + end + end + end end shared_examples 'sending email' do |branches_to_be_notified: nil| @@ -50,7 +86,7 @@ RSpec.describe Integrations::PipelinesEmail, :mailer do it 'sends email' do emails = receivers.map { |r| double(notification_email_or_default: r) } - should_only_email(*emails, kind: :bcc) + should_only_email(*emails) end end diff --git a/spec/models/integrations/shimo_spec.rb b/spec/models/integrations/shimo_spec.rb new file mode 100644 index 00000000000..25df8d2b249 --- /dev/null +++ b/spec/models/integrations/shimo_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Integrations::Shimo do + describe '#fields' do + let(:shimo_integration) { create(:shimo_integration) } + + it 'returns custom fields' do + expect(shimo_integration.fields.pluck(:name)).to eq(%w[external_wiki_url]) + end + end + + describe '#create' do + let(:project) { create(:project, :repository) } + let(:external_wiki_url) { 'https://shimo.example.com/desktop' } + let(:params) { { active: true, project: project, external_wiki_url: external_wiki_url } } + + context 'with valid params' do + it 'creates the Shimo integration' do + shimo = described_class.create!(params) + + expect(shimo.valid?).to be true + expect(shimo.render?).to be true + expect(shimo.external_wiki_url).to eq(external_wiki_url) + end + end + + context 'with invalid params' do + it 'cannot create the Shimo integration without external_wiki_url' do + params['external_wiki_url'] = nil + expect { described_class.create!(params) }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'cannot create the Shimo integration with invalid external_wiki_url' do + params['external_wiki_url'] = 'Fake Invalid URL' + expect { described_class.create!(params) }.to raise_error(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/spec/models/integrations/zentao_spec.rb b/spec/models/integrations/zentao_spec.rb index a1503ecc092..2b0532c7930 100644 --- a/spec/models/integrations/zentao_spec.rb +++ b/spec/models/integrations/zentao_spec.rb @@ -50,4 +50,10 @@ RSpec.describe Integrations::Zentao do expect(zentao_integration.test).to eq(test_response) end end + + describe '#help' do + it 'renders prompt information' do + expect(zentao_integration.help).not_to be_empty + end + end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 4319407706e..ba4429451d1 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -34,7 +34,8 @@ RSpec.describe Issue do it { is_expected.to have_many(:issue_email_participants) } it { is_expected.to have_many(:timelogs).autosave(true) } it { is_expected.to have_one(:incident_management_issuable_escalation_status) } - it { is_expected.to have_and_belong_to_many(:customer_relations_contacts) } + it { is_expected.to have_many(:issue_customer_relations_contacts) } + it { is_expected.to have_many(:customer_relations_contacts) } describe 'versions.most_recent' do it 'returns the most recent version' do @@ -305,7 +306,7 @@ RSpec.describe Issue do end describe '#reopen' do - let(:issue) { create(:issue, project: reusable_project, state: 'closed', closed_at: Time.current, closed_by: user) } + let_it_be_with_reload(:issue) { create(:issue, project: reusable_project, state: 'closed', closed_at: Time.current, closed_by: user) } it 'sets closed_at to nil when an issue is reopened' do expect { issue.reopen }.to change { issue.closed_at }.to(nil) @@ -315,6 +316,22 @@ RSpec.describe Issue do expect { issue.reopen }.to change { issue.closed_by }.from(user).to(nil) end + it 'clears moved_to_id for moved issues' do + moved_issue = create(:issue) + + issue.update!(moved_to_id: moved_issue.id) + + expect { issue.reopen }.to change { issue.moved_to_id }.from(moved_issue.id).to(nil) + end + + it 'clears duplicated_to_id for duplicated issues' do + duplicate_issue = create(:issue) + + issue.update!(duplicated_to_id: duplicate_issue.id) + + expect { issue.reopen }.to change { issue.duplicated_to_id }.from(duplicate_issue.id).to(nil) + end + it 'changes the state to opened' do expect { issue.reopen }.to change { issue.state_id }.from(described_class.available_states[:closed]).to(described_class.available_states[:opened]) end @@ -1218,7 +1235,7 @@ RSpec.describe Issue do end it 'returns public and hidden issues' do - expect(described_class.public_only).to eq([public_issue, hidden_issue]) + expect(described_class.public_only).to contain_exactly(public_issue, hidden_issue) end end end @@ -1247,7 +1264,7 @@ RSpec.describe Issue do end it 'returns public and hidden issues' do - expect(described_class.without_hidden).to eq([public_issue, hidden_issue]) + expect(described_class.without_hidden).to contain_exactly(public_issue, hidden_issue) end end end diff --git a/spec/models/jira_import_state_spec.rb b/spec/models/jira_import_state_spec.rb index e982b7353ba..a272d001429 100644 --- a/spec/models/jira_import_state_spec.rb +++ b/spec/models/jira_import_state_spec.rb @@ -175,7 +175,7 @@ RSpec.describe JiraImportState do let(:jira_import) { build(:jira_import_state, project: project)} it 'does not run the callback', :aggregate_failures do - expect { jira_import.save }.to change { JiraImportState.count }.by(1) + expect { jira_import.save! }.to change { JiraImportState.count }.by(1) expect(jira_import.reload.error_message).to be_nil end end @@ -184,7 +184,7 @@ RSpec.describe JiraImportState do let(:jira_import) { build(:jira_import_state, project: project, error_message: 'error')} it 'does not run the callback', :aggregate_failures do - expect { jira_import.save }.to change { JiraImportState.count }.by(1) + expect { jira_import.save! }.to change { JiraImportState.count }.by(1) expect(jira_import.reload.error_message).to eq('error') end end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 7468c1b9f0a..d41a1604211 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -85,9 +85,9 @@ RSpec.describe Key, :mailer do let_it_be(:expiring_soon_notified) { create(:key, expires_at: 4.days.from_now, user: user, before_expiry_notification_delivered_at: Time.current) } let_it_be(:future_expiry) { create(:key, expires_at: 1.month.from_now, user: user) } - describe '.expired_and_not_notified' do + describe '.expired_today_and_not_notified' do it 'returns keys that expire today and in the past' do - expect(described_class.expired_and_not_notified).to contain_exactly(expired_today_not_notified, expired_yesterday) + expect(described_class.expired_today_and_not_notified).to contain_exactly(expired_today_not_notified) end end diff --git a/spec/models/loose_foreign_keys/deleted_record_spec.rb b/spec/models/loose_foreign_keys/deleted_record_spec.rb new file mode 100644 index 00000000000..cd5068bdb52 --- /dev/null +++ b/spec/models/loose_foreign_keys/deleted_record_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe LooseForeignKeys::DeletedRecord, type: :model do + let_it_be(:table) { 'public.projects' } + + let_it_be(:deleted_record_1) { described_class.create!(partition: 1, fully_qualified_table_name: table, primary_key_value: 5) } + let_it_be(:deleted_record_2) { described_class.create!(partition: 1, fully_qualified_table_name: table, primary_key_value: 1) } + let_it_be(:deleted_record_3) { described_class.create!(partition: 1, fully_qualified_table_name: 'public.other_table', primary_key_value: 3) } + let_it_be(:deleted_record_4) { described_class.create!(partition: 1, fully_qualified_table_name: table, primary_key_value: 1) } # duplicate + + describe '.load_batch_for_table' do + it 'loads records and orders them by creation date' do + records = described_class.load_batch_for_table(table, 10) + + expect(records).to eq([deleted_record_1, deleted_record_2, deleted_record_4]) + end + + it 'supports configurable batch size' do + records = described_class.load_batch_for_table(table, 2) + + expect(records).to eq([deleted_record_1, deleted_record_2]) + end + end + + describe '.mark_records_processed' do + it 'updates all records' do + described_class.mark_records_processed([deleted_record_1, deleted_record_2, deleted_record_4]) + + expect(described_class.status_pending.count).to eq(1) + expect(described_class.status_processed.count).to eq(3) + end + end +end diff --git a/spec/models/loose_foreign_keys/modification_tracker_spec.rb b/spec/models/loose_foreign_keys/modification_tracker_spec.rb new file mode 100644 index 00000000000..069ccf85141 --- /dev/null +++ b/spec/models/loose_foreign_keys/modification_tracker_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe LooseForeignKeys::ModificationTracker do + subject(:tracker) { described_class.new } + + describe '#over_limit?' do + it 'is true when deletion MAX_DELETES is exceeded' do + stub_const('LooseForeignKeys::ModificationTracker::MAX_DELETES', 5) + + tracker.add_deletions('issues', 10) + expect(tracker).to be_over_limit + end + + it 'is false when MAX_DELETES is not exceeded' do + tracker.add_deletions('issues', 3) + + expect(tracker).not_to be_over_limit + end + + it 'is true when deletion MAX_UPDATES is exceeded' do + stub_const('LooseForeignKeys::ModificationTracker::MAX_UPDATES', 5) + + tracker.add_updates('issues', 3) + tracker.add_updates('issues', 4) + + expect(tracker).to be_over_limit + end + + it 'is false when MAX_UPDATES is not exceeded' do + tracker.add_updates('projects', 3) + + expect(tracker).not_to be_over_limit + end + + it 'is true when max runtime is exceeded' do + monotonic_time_before = 1 # this will be the start time + monotonic_time_after = described_class::MAX_RUNTIME.to_i + 1 # this will be returned when over_limit? is called + + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after) + + tracker + + expect(tracker).to be_over_limit + end + + it 'is false when max runtime is not exceeded' do + expect(tracker).not_to be_over_limit + end + end + + describe '#add_deletions' do + it 'increments a Prometheus counter' do + counter = Gitlab::Metrics.registry.get(:loose_foreign_key_deletions) + + subject.add_deletions(:users, 4) + + expect(counter.get(table: :users)).to eq(4) + end + end + + describe '#add_updates' do + it 'increments a Prometheus counter' do + counter = Gitlab::Metrics.registry.get(:loose_foreign_key_updates) + + subject.add_updates(:users, 4) + + expect(counter.get(table: :users)).to eq(4) + end + end + + describe '#stats' do + it 'exposes stats' do + freeze_time do + tracker + tracker.add_deletions('issues', 5) + tracker.add_deletions('issues', 2) + tracker.add_deletions('projects', 2) + + tracker.add_updates('projects', 3) + + expect(tracker.stats).to eq({ + over_limit: false, + delete_count_by_table: { 'issues' => 7, 'projects' => 2 }, + update_count_by_table: { 'projects' => 3 }, + delete_count: 9, + update_count: 3 + }) + end + end + end +end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index afe78adc547..abff1815f1a 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Member do describe 'Associations' do it { is_expected.to belong_to(:user) } + it { is_expected.to have_one(:member_task) } end describe 'Validation' do @@ -678,6 +679,19 @@ RSpec.describe Member do expect(member.invite_token).not_to be_nil expect_any_instance_of(Member).not_to receive(:after_accept_invite) end + + it 'schedules a TasksToBeDone::CreateWorker task' do + stub_experiments(invite_members_for_task: true) + + member_task = create(:member_task, member: member, project: member.project) + + expect(TasksToBeDone::CreateWorker) + .to receive(:perform_async) + .with(member_task.id, member.created_by_id, [user.id]) + .once + + member.accept_invite!(user) + end end describe '#decline_invite!' do diff --git a/spec/models/members/member_task_spec.rb b/spec/models/members/member_task_spec.rb new file mode 100644 index 00000000000..b06aa05c255 --- /dev/null +++ b/spec/models/members/member_task_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MemberTask do + describe 'Associations' do + it { is_expected.to belong_to(:member) } + it { is_expected.to belong_to(:project) } + end + + describe 'Validations' do + it { is_expected.to validate_presence_of(:member) } + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_inclusion_of(:tasks).in_array(MemberTask::TASKS.values) } + + describe 'unique tasks validation' do + subject do + build(:member_task, tasks: [0, 0]) + end + + it 'expects the task values to be unique' do + expect(subject).to be_invalid + expect(subject.errors[:tasks]).to include('are not unique') + end + end + + describe 'project validations' do + let_it_be(:project) { create(:project) } + + subject do + build(:member_task, member: member, project: project, tasks_to_be_done: [:ci, :code]) + end + + context 'when the member source is a group' do + let_it_be(:member) { create(:group_member) } + + it "expects the project to be part of the member's group projects" do + expect(subject).to be_invalid + expect(subject.errors[:project]).to include('is not in the member group') + end + + context "when the project is part of the member's group projects" do + let_it_be(:project) { create(:project, namespace: member.source) } + + it { is_expected.to be_valid } + end + end + + context 'when the member source is a project' do + let_it_be(:member) { create(:project_member) } + + it "expects the project to be the member's project" do + expect(subject).to be_invalid + expect(subject.errors[:project]).to include('is not the member project') + end + + context "when the project is the member's project" do + let_it_be(:project) { member.source } + + it { is_expected.to be_valid } + end + end + end + end + + describe '.for_members' do + it 'returns the member_tasks for multiple members' do + member1 = create(:group_member) + member_task1 = create(:member_task, member: member1) + create(:member_task) + expect(described_class.for_members([member1])).to match_array([member_task1]) + end + end + + describe '#tasks_to_be_done' do + subject { member_task.tasks_to_be_done } + + let_it_be(:member_task) { build(:member_task) } + + before do + member_task[:tasks] = [0, 1] + end + + it 'returns an array of symbols for the corresponding integers' do + expect(subject).to match_array([:ci, :code]) + end + end + + describe '#tasks_to_be_done=' do + let_it_be(:member_task) { build(:member_task) } + + context 'when passing valid values' do + subject { member_task[:tasks] } + + before do + member_task.tasks_to_be_done = tasks + end + + context 'when passing tasks as strings' do + let_it_be(:tasks) { %w(ci code) } + + it 'sets an array of integers for the corresponding tasks' do + expect(subject).to match_array([0, 1]) + end + end + + context 'when passing a single task' do + let_it_be(:tasks) { :ci } + + it 'sets an array of integers for the corresponding tasks' do + expect(subject).to match_array([1]) + end + end + + context 'when passing a task twice' do + let_it_be(:tasks) { %w(ci ci) } + + it 'is set only once' do + expect(subject).to match_array([1]) + end + end + end + end +end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index ca846cf9e8e..031caefbd43 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -256,59 +256,5 @@ RSpec.describe ProjectMember do it_behaves_like 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' end - - context 'when the feature flag `specialized_service_for_project_member_auth_refresh` is disabled' do - before do - stub_feature_flags(specialized_service_for_project_member_auth_refresh: false) - end - - shared_examples_for 'calls UserProjectAccessChangedService to recalculate authorizations' do - it 'calls UserProjectAccessChangedService' do - expect_next_instance_of(UserProjectAccessChangedService, user.id) do |service| - expect(service).to receive(:execute) - end - - action - end - end - - context 'on create' do - let(:action) { project.add_user(user, Gitlab::Access::GUEST) } - - it 'changes access level' do - expect { action }.to change { user.can?(:guest_access, project) }.from(false).to(true) - end - - it_behaves_like 'calls UserProjectAccessChangedService to recalculate authorizations' - end - - context 'on update' do - let(:action) { project.members.find_by(user: user).update!(access_level: Gitlab::Access::DEVELOPER) } - - before do - project.add_user(user, Gitlab::Access::GUEST) - end - - it 'changes access level' do - expect { action }.to change { user.can?(:developer_access, project) }.from(false).to(true) - end - - it_behaves_like 'calls UserProjectAccessChangedService to recalculate authorizations' - end - - context 'on destroy' do - let(:action) { project.members.find_by(user: user).destroy! } - - before do - project.add_user(user, Gitlab::Access::GUEST) - end - - it 'changes access level', :sidekiq_inline do - expect { action }.to change { user.can?(:guest_access, project) }.from(true).to(false) - end - - it_behaves_like 'calls UserProjectAccessChangedService to recalculate authorizations' - end - end end end diff --git a/spec/models/merge_request_assignee_spec.rb b/spec/models/merge_request_assignee_spec.rb index d287392bf7f..5bb8e7184a3 100644 --- a/spec/models/merge_request_assignee_spec.rb +++ b/spec/models/merge_request_assignee_spec.rb @@ -37,4 +37,8 @@ RSpec.describe MergeRequestAssignee do end end end + + it_behaves_like 'having unique enum values' + + it_behaves_like 'having reviewer state' end diff --git a/spec/models/merge_request_diff_commit_spec.rb b/spec/models/merge_request_diff_commit_spec.rb index adddec7ced8..25e5e40feb7 100644 --- a/spec/models/merge_request_diff_commit_spec.rb +++ b/spec/models/merge_request_diff_commit_spec.rb @@ -46,11 +46,7 @@ RSpec.describe MergeRequestDiffCommit do { "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", "authored_date": "2014-02-27T10:01:38.000+01:00".to_time, - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T10:01:38.000+01:00".to_time, - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author_id": an_instance_of(Integer), "committer_id": an_instance_of(Integer), "merge_request_diff_id": merge_request_diff_id, @@ -61,11 +57,7 @@ RSpec.describe MergeRequestDiffCommit do { "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", "authored_date": "2014-02-27T09:57:31.000+01:00".to_time, - "author_name": "Dmitriy Zaporozhets", - "author_email": "dmitriy.zaporozhets@gmail.com", "committed_date": "2014-02-27T09:57:31.000+01:00".to_time, - "committer_name": "Dmitriy Zaporozhets", - "committer_email": "dmitriy.zaporozhets@gmail.com", "commit_author_id": an_instance_of(Integer), "committer_id": an_instance_of(Integer), "merge_request_diff_id": merge_request_diff_id, @@ -79,7 +71,7 @@ RSpec.describe MergeRequestDiffCommit do subject { described_class.create_bulk(merge_request_diff_id, commits) } it 'inserts the commits into the database en masse' do - expect(Gitlab::Database.main).to receive(:bulk_insert) + expect(ApplicationRecord).to receive(:legacy_bulk_insert) .with(described_class.table_name, rows) subject @@ -111,11 +103,7 @@ RSpec.describe MergeRequestDiffCommit do [{ "message": "Weird commit date\n", "authored_date": timestamp, - "author_name": "Alejandro Rodríguez", - "author_email": "alejorro70@gmail.com", "committed_date": timestamp, - "committer_name": "Alejandro Rodríguez", - "committer_email": "alejorro70@gmail.com", "commit_author_id": an_instance_of(Integer), "committer_id": an_instance_of(Integer), "merge_request_diff_id": merge_request_diff_id, @@ -126,7 +114,7 @@ RSpec.describe MergeRequestDiffCommit do end it 'uses a sanitized date' do - expect(Gitlab::Database.main).to receive(:bulk_insert) + expect(ApplicationRecord).to receive(:legacy_bulk_insert) .with(described_class.table_name, rows) subject diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index 5fff880c44e..afe7251f59a 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -240,8 +240,8 @@ RSpec.describe MergeRequestDiff do stub_external_diffs_setting(enabled: true) expect(diff).not_to receive(:save!) - expect(Gitlab::Database.main) - .to receive(:bulk_insert) + expect(ApplicationRecord) + .to receive(:legacy_bulk_insert) .with('merge_request_diff_files', anything) .and_raise(ActiveRecord::Rollback) @@ -1080,6 +1080,22 @@ RSpec.describe MergeRequestDiff do end end + describe '#commits' do + include ProjectForksHelper + + let_it_be(:target) { create(:project, :test_repo) } + let_it_be(:forked) { fork_project(target, nil, repository: true) } + let_it_be(:mr) { create(:merge_request, source_project: forked, target_project: target) } + + it 'returns a CommitCollection whose container points to the target project' do + expect(mr.merge_request_diff.commits.container).to eq(target) + end + + it 'returns a non-empty CommitCollection' do + expect(mr.merge_request_diff.commits.commits.size).to be > 0 + end + end + describe '.latest_diff_for_merge_requests' do let_it_be(:merge_request_1) { create(:merge_request_without_merge_request_diff) } let_it_be(:merge_request_1_diff_1) { create(:merge_request_diff, merge_request: merge_request_1, created_at: 3.days.ago) } diff --git a/spec/models/merge_request_reviewer_spec.rb b/spec/models/merge_request_reviewer_spec.rb index 76b44abca54..d69d60c94f0 100644 --- a/spec/models/merge_request_reviewer_spec.rb +++ b/spec/models/merge_request_reviewer_spec.rb @@ -7,6 +7,10 @@ RSpec.describe MergeRequestReviewer do subject { merge_request.merge_request_reviewers.build(reviewer: create(:user)) } + it_behaves_like 'having unique enum values' + + it_behaves_like 'having reviewer state' + describe 'associations' do it { is_expected.to belong_to(:merge_request).class_name('MergeRequest') } it { is_expected.to belong_to(:reviewer).class_name('User').inverse_of(:merge_request_reviewers) } diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index d871453e062..5618fb06157 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1638,6 +1638,22 @@ RSpec.describe MergeRequest, factory_default: :keep do expect(request.default_merge_commit_message) .not_to match("By removing all code\n\n") end + + it 'uses template from target project' do + request = build(:merge_request, title: 'Fix everything') + subject.target_project.merge_commit_template = '%{title}' + + expect(request.default_merge_commit_message) + .to eq('Fix everything') + end + + it 'ignores template when include_description is true' do + request = build(:merge_request, title: 'Fix everything') + subject.target_project.merge_commit_template = '%{title}' + + expect(request.default_merge_commit_message(include_description: true)) + .to match("See merge request #{request.to_reference(full: true)}") + end end describe "#auto_merge_strategy" do @@ -2904,6 +2920,8 @@ RSpec.describe MergeRequest, factory_default: :keep do params = {} merge_jid = 'hash-123' + allow(MergeWorker).to receive(:with_status).and_return(MergeWorker) + expect(merge_request).to receive(:expire_etag_cache) expect(MergeWorker).to receive(:perform_async).with(merge_request.id, user_id, params) do merge_jid @@ -2922,6 +2940,10 @@ RSpec.describe MergeRequest, factory_default: :keep do subject(:execute) { merge_request.rebase_async(user_id) } + before do + allow(RebaseWorker).to receive(:with_status).and_return(RebaseWorker) + end + it 'atomically enqueues a RebaseWorker job and updates rebase_jid' do expect(RebaseWorker) .to receive(:perform_async) diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index c201d89947e..8f5860c799c 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -28,6 +28,41 @@ RSpec.describe Namespace do it { is_expected.to have_one :onboarding_progress } it { is_expected.to have_one :admin_note } it { is_expected.to have_many :pending_builds } + + describe '#children' do + let_it_be(:group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:project_with_namespace) { create(:project, namespace: group) } + + it 'excludes project namespaces' do + expect(project_with_namespace.project_namespace.parent).to eq(group) + expect(group.children).to match_array([subgroup]) + end + end + end + + shared_examples 'validations called by different namespace types' do |method| + using RSpec::Parameterized::TableSyntax + + where(:namespace_type, :call_validation) do + :namespace | true + :group | true + :user_namespace | true + :project_namespace | false + end + + with_them do + it 'conditionally runs given validation' do + namespace = build(namespace_type) + if call_validation + expect(namespace).to receive(method) + else + expect(namespace).not_to receive(method) + end + + namespace.valid? + end + end end describe 'validations' do @@ -50,10 +85,10 @@ RSpec.describe Namespace do ref(:project_sti_name) | ref(:user_sti_name) | 'project namespace cannot be the parent of another namespace' ref(:project_sti_name) | ref(:group_sti_name) | 'project namespace cannot be the parent of another namespace' ref(:project_sti_name) | ref(:project_sti_name) | 'project namespace cannot be the parent of another namespace' - ref(:group_sti_name) | ref(:user_sti_name) | 'cannot not be used for user namespace' + ref(:group_sti_name) | ref(:user_sti_name) | 'cannot be used for user namespace' ref(:group_sti_name) | ref(:group_sti_name) | nil ref(:group_sti_name) | ref(:project_sti_name) | nil - ref(:user_sti_name) | ref(:user_sti_name) | 'cannot not be used for user namespace' + ref(:user_sti_name) | ref(:user_sti_name) | 'cannot be used for user namespace' ref(:user_sti_name) | ref(:group_sti_name) | 'user namespace cannot be the parent of another namespace' ref(:user_sti_name) | ref(:project_sti_name) | nil end @@ -102,14 +137,20 @@ RSpec.describe Namespace do end end - it 'does not allow too deep nesting' do - ancestors = (1..21).to_a - group = build(:group) + describe '#nesting_level_allowed' do + context 'for a group' do + it 'does not allow too deep nesting' do + ancestors = (1..21).to_a + group = build(:group) + + allow(group).to receive(:ancestors).and_return(ancestors) - allow(group).to receive(:ancestors).and_return(ancestors) + expect(group).not_to be_valid + expect(group.errors[:parent_id].first).to eq('has too deep level of nesting') + end + end - expect(group).not_to be_valid - expect(group.errors[:parent_id].first).to eq('has too deep level of nesting') + it_behaves_like 'validations called by different namespace types', :nesting_level_allowed end describe 'reserved path validation' do @@ -188,7 +229,7 @@ RSpec.describe Namespace do expect(namespace.path).to eq('j') - namespace.update(name: 'something new') + namespace.update!(name: 'something new') expect(namespace).to be_valid expect(namespace.name).to eq('something new') @@ -199,8 +240,10 @@ RSpec.describe Namespace do let(:namespace) { build(:project_namespace) } it 'allows to update path to single char' do - namespace = create(:project_namespace) - namespace.update(path: 'j') + project = create(:project) + namespace = project.project_namespace + + namespace.update!(path: 'j') expect(namespace).to be_valid end @@ -244,7 +287,7 @@ RSpec.describe Namespace do end end - context 'creating a default Namespace' do + context 'creating a Namespace with nil type' do let(:namespace_type) { nil } it 'is the correct type of namespace' do @@ -255,7 +298,7 @@ RSpec.describe Namespace do end context 'creating an unknown Namespace type' do - let(:namespace_type) { 'One' } + let(:namespace_type) { 'nonsense' } it 'creates a default Namespace' do expect(Namespace.find(namespace.id)).to be_a(Namespace) @@ -273,8 +316,8 @@ RSpec.describe Namespace do describe '.by_parent' do it 'includes correct namespaces' do - expect(described_class.by_parent(namespace1.id)).to eq([namespace1sub]) - expect(described_class.by_parent(namespace2.id)).to eq([namespace2sub]) + expect(described_class.by_parent(namespace1.id)).to match_array([namespace1sub]) + expect(described_class.by_parent(namespace2.id)).to match_array([namespace2sub]) expect(described_class.by_parent(nil)).to match_array([namespace, namespace1, namespace2]) end end @@ -302,9 +345,13 @@ RSpec.describe Namespace do describe '.without_project_namespaces' do let_it_be(:user_namespace) { create(:user_namespace) } - let_it_be(:project_namespace) { create(:project_namespace) } + let_it_be(:project) { create(:project) } + let_it_be(:project_namespace) { project.project_namespace } it 'excludes project namespaces' do + expect(project_namespace).not_to be_nil + expect(project_namespace.parent).not_to be_nil + expect(described_class.all).to include(project_namespace) expect(described_class.without_project_namespaces).to match_array([namespace, namespace1, namespace2, namespace1sub, namespace2sub, user_namespace, project_namespace.parent]) end end @@ -519,6 +566,25 @@ RSpec.describe Namespace do it 'returns namespaces with a matching route path regardless of the casing' do expect(described_class.search('PARENT-PATH/NEW-PATH', include_parents: true)).to eq([second_group]) end + + context 'with project namespaces' do + let_it_be(:project) { create(:project, namespace: parent_group, path: 'some-new-path') } + let_it_be(:project_namespace) { project.project_namespace } + + it 'does not return project namespace' do + search_result = described_class.search('path') + + expect(search_result).not_to include(project_namespace) + expect(search_result).to match_array([first_group, parent_group, second_group]) + end + + it 'does not return project namespace when including parents' do + search_result = described_class.search('path', include_parents: true) + + expect(search_result).not_to include(project_namespace) + expect(search_result).to match_array([first_group, parent_group, second_group]) + end + end end describe '.with_statistics' do @@ -528,26 +594,30 @@ RSpec.describe Namespace do create(:project, namespace: namespace, statistics: build(:project_statistics, - namespace: namespace, - repository_size: 101, - wiki_size: 505, - lfs_objects_size: 202, - build_artifacts_size: 303, - packages_size: 404, - snippets_size: 605)) + namespace: namespace, + repository_size: 101, + wiki_size: 505, + lfs_objects_size: 202, + build_artifacts_size: 303, + pipeline_artifacts_size: 707, + packages_size: 404, + snippets_size: 605, + uploads_size: 808)) end let(:project2) do create(:project, namespace: namespace, statistics: build(:project_statistics, - namespace: namespace, - repository_size: 10, - wiki_size: 50, - lfs_objects_size: 20, - build_artifacts_size: 30, - packages_size: 40, - snippets_size: 60)) + namespace: namespace, + repository_size: 10, + wiki_size: 50, + lfs_objects_size: 20, + build_artifacts_size: 30, + pipeline_artifacts_size: 70, + packages_size: 40, + snippets_size: 60, + uploads_size: 80)) end it "sums all project storage counters in the namespace" do @@ -555,13 +625,15 @@ RSpec.describe Namespace do project2 statistics = described_class.with_statistics.find(namespace.id) - expect(statistics.storage_size).to eq 2330 + expect(statistics.storage_size).to eq 3995 expect(statistics.repository_size).to eq 111 expect(statistics.wiki_size).to eq 555 expect(statistics.lfs_objects_size).to eq 222 expect(statistics.build_artifacts_size).to eq 333 + expect(statistics.pipeline_artifacts_size).to eq 777 expect(statistics.packages_size).to eq 444 expect(statistics.snippets_size).to eq 665 + expect(statistics.uploads_size).to eq 888 end it "correctly handles namespaces without projects" do @@ -572,8 +644,10 @@ RSpec.describe Namespace do expect(statistics.wiki_size).to eq 0 expect(statistics.lfs_objects_size).to eq 0 expect(statistics.build_artifacts_size).to eq 0 + expect(statistics.pipeline_artifacts_size).to eq 0 expect(statistics.packages_size).to eq 0 expect(statistics.snippets_size).to eq 0 + expect(statistics.uploads_size).to eq 0 end end @@ -673,7 +747,7 @@ RSpec.describe Namespace do end it "moves dir if path changed" do - namespace.update(path: namespace.full_path + '_new') + namespace.update!(path: namespace.full_path + '_new') expect(gitlab_shell.repository_exists?(project.repository_storage, "#{namespace.path}/#{project.path}.git")).to be_truthy end @@ -684,7 +758,7 @@ RSpec.describe Namespace do expect(namespace).to receive(:write_projects_repository_config).and_raise('foo') expect do - namespace.update(path: namespace.full_path + '_new') + namespace.update!(path: namespace.full_path + '_new') end.to raise_error('foo') end end @@ -701,7 +775,7 @@ RSpec.describe Namespace do end expect(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(false) # like prod - namespace.update(path: namespace.full_path + '_new') + namespace.update!(path: namespace.full_path + '_new') end end end @@ -931,7 +1005,7 @@ RSpec.describe Namespace do it "repository directory remains unchanged if path changed" do before_disk_path = project.disk_path - namespace.update(path: namespace.full_path + '_new') + namespace.update!(path: namespace.full_path + '_new') expect(before_disk_path).to eq(project.disk_path) expect(gitlab_shell.repository_exists?(project.repository_storage, "#{project.disk_path}.git")).to be_truthy @@ -946,7 +1020,7 @@ RSpec.describe Namespace do let!(:legacy_project_in_subgroup) { create(:project, :legacy_storage, :repository, namespace: subgroup, name: 'foo3') } it 'updates project full path in .git/config' do - parent.update(path: 'mygroup_new') + parent.update!(path: 'mygroup_new') expect(project_rugged(project_in_parent_group).config['gitlab.fullpath']).to eq "mygroup_new/#{project_in_parent_group.path}" expect(project_rugged(hashed_project_in_subgroup).config['gitlab.fullpath']).to eq "mygroup_new/mysubgroup/#{hashed_project_in_subgroup.path}" @@ -958,7 +1032,7 @@ RSpec.describe Namespace do repository_hashed_project_in_subgroup = hashed_project_in_subgroup.project_repository repository_legacy_project_in_subgroup = legacy_project_in_subgroup.project_repository - parent.update(path: 'mygroup_moved') + parent.update!(path: 'mygroup_moved') expect(repository_project_in_parent_group.reload.disk_path).to eq "mygroup_moved/#{project_in_parent_group.path}" expect(repository_hashed_project_in_subgroup.reload.disk_path).to eq hashed_project_in_subgroup.disk_path @@ -992,7 +1066,7 @@ RSpec.describe Namespace do it 'renames its dirs when deleted' do allow(GitlabShellWorker).to receive(:perform_in) - namespace.destroy + namespace.destroy! expect(File.exist?(deleted_path_in_dir)).to be(true) end @@ -1000,7 +1074,7 @@ RSpec.describe Namespace do it 'schedules the namespace for deletion' do expect(GitlabShellWorker).to receive(:perform_in).with(5.minutes, :rm_namespace, repository_storage, deleted_path) - namespace.destroy + namespace.destroy! end context 'in sub-groups' do @@ -1014,7 +1088,7 @@ RSpec.describe Namespace do it 'renames its dirs when deleted' do allow(GitlabShellWorker).to receive(:perform_in) - child.destroy + child.destroy! expect(File.exist?(deleted_path_in_dir)).to be(true) end @@ -1022,7 +1096,7 @@ RSpec.describe Namespace do it 'schedules the namespace for deletion' do expect(GitlabShellWorker).to receive(:perform_in).with(5.minutes, :rm_namespace, repository_storage, deleted_path) - child.destroy + child.destroy! end end end @@ -1035,7 +1109,7 @@ RSpec.describe Namespace do expect(File.exist?(path_in_dir)).to be(false) - namespace.destroy + namespace.destroy! expect(File.exist?(deleted_path_in_dir)).to be(false) end @@ -1293,6 +1367,7 @@ RSpec.describe Namespace do context 'refreshing project access on updating share_with_group_lock' do let(:group) { create(:group, share_with_group_lock: false) } let(:project) { create(:project, :private, group: group) } + let(:another_project) { create(:project, :private, group: group) } let_it_be(:shared_with_group_one) { create(:group) } let_it_be(:shared_with_group_two) { create(:group) } @@ -1305,6 +1380,7 @@ RSpec.describe Namespace do shared_with_group_one.add_developer(group_one_user) shared_with_group_two.add_developer(group_two_user) create(:project_group_link, group: shared_with_group_one, project: project) + create(:project_group_link, group: shared_with_group_one, project: another_project) create(:project_group_link, group: shared_with_group_two, project: project) end @@ -1312,6 +1388,9 @@ RSpec.describe Namespace do expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) .to receive(:perform_async).with(project.id).once + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) + .to receive(:perform_async).with(another_project.id).once + execute_update end @@ -1344,11 +1423,23 @@ RSpec.describe Namespace do stub_feature_flags(specialized_worker_for_group_lock_update_auth_recalculation: false) end - it 'refreshes the permissions of the members of the old and new namespace' do + it 'updates authorizations leading to users from shared groups losing access', :sidekiq_inline do expect { execute_update } .to change { group_one_user.authorized_projects.include?(project) }.from(true).to(false) .and change { group_two_user.authorized_projects.include?(project) }.from(true).to(false) end + + it 'updates the authorizations in a non-blocking manner' do + expect(AuthorizedProjectsWorker).to( + receive(:bulk_perform_async) + .with([[group_one_user.id]])).once + + expect(AuthorizedProjectsWorker).to( + receive(:bulk_perform_async) + .with([[group_two_user.id]])).once + + execute_update + end end end @@ -1544,7 +1635,7 @@ RSpec.describe Namespace do it 'returns the path before last save' do group = create(:group) - group.update(parent: nil) + group.update!(parent: nil) expect(group.full_path_before_last_save).to eq(group.path_before_last_save) end @@ -1555,7 +1646,7 @@ RSpec.describe Namespace do group = create(:group, parent: nil) parent = create(:group) - group.update(parent: parent) + group.update!(parent: parent) expect(group.full_path_before_last_save).to eq("#{group.path_before_last_save}") end @@ -1566,7 +1657,7 @@ RSpec.describe Namespace do parent = create(:group) group = create(:group, parent: parent) - group.update(parent: nil) + group.update!(parent: nil) expect(group.full_path_before_last_save).to eq("#{parent.full_path}/#{group.path}") end @@ -1578,7 +1669,7 @@ RSpec.describe Namespace do group = create(:group, parent: parent) new_parent = create(:group) - group.update(parent: new_parent) + group.update!(parent: new_parent) expect(group.full_path_before_last_save).to eq("#{parent.full_path}/#{group.path}") end @@ -1845,87 +1936,95 @@ RSpec.describe Namespace do end context 'with a parent' do - context 'when parent has shared runners disabled' do - let(:parent) { create(:group, :shared_runners_disabled) } - let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } - - it 'is invalid' do - expect(group).to be_invalid - expect(group.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group has shared Runners disabled') + context 'when namespace is a group' do + context 'when parent has shared runners disabled' do + let(:parent) { create(:group, :shared_runners_disabled) } + let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } + + it 'is invalid' do + expect(group).to be_invalid + expect(group.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group has shared Runners disabled') + end end - end - context 'when parent has shared runners disabled but allows override' do - let(:parent) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) } - let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } + context 'when parent has shared runners disabled but allows override' do + let(:parent) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) } + let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } - it 'is valid' do - expect(group).to be_valid + it 'is valid' do + expect(group).to be_valid + end end - end - context 'when parent has shared runners enabled' do - let(:parent) { create(:group, shared_runners_enabled: true) } - let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } + context 'when parent has shared runners enabled' do + let(:parent) { create(:group, shared_runners_enabled: true) } + let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } - it 'is valid' do - expect(group).to be_valid + it 'is valid' do + expect(group).to be_valid + end end end end + + it_behaves_like 'validations called by different namespace types', :changing_shared_runners_enabled_is_allowed end describe 'validation #changing_allow_descendants_override_disabled_shared_runners_is_allowed' do - context 'without a parent' do - context 'with shared runners disabled' do - let(:namespace) { build(:namespace, :allow_descendants_override_disabled_shared_runners, :shared_runners_disabled) } + context 'when namespace is a group' do + context 'without a parent' do + context 'with shared runners disabled' do + let(:namespace) { build(:group, :allow_descendants_override_disabled_shared_runners, :shared_runners_disabled) } - it 'is valid' do - expect(namespace).to be_valid + it 'is valid' do + expect(namespace).to be_valid + end end - end - context 'with shared runners enabled' do - let(:namespace) { create(:namespace) } + context 'with shared runners enabled' do + let(:namespace) { create(:namespace) } - it 'is invalid' do - namespace.allow_descendants_override_disabled_shared_runners = true + it 'is invalid' do + namespace.allow_descendants_override_disabled_shared_runners = true - expect(namespace).to be_invalid - expect(namespace.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be changed if shared runners are enabled') + expect(namespace).to be_invalid + expect(namespace.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be changed if shared runners are enabled') + end end end - end - context 'with a parent' do - context 'when parent does not allow shared runners' do - let(:parent) { create(:group, :shared_runners_disabled) } - let(:group) { build(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) } + context 'with a parent' do + context 'when parent does not allow shared runners' do + let(:parent) { create(:group, :shared_runners_disabled) } + let(:group) { build(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) } - it 'is invalid' do - expect(group).to be_invalid - expect(group.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be enabled because parent group does not allow it') + it 'is invalid' do + expect(group).to be_invalid + expect(group.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be enabled because parent group does not allow it') + end end - end - context 'when parent allows shared runners and setting to true' do - let(:parent) { create(:group, shared_runners_enabled: true) } - let(:group) { build(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) } + context 'when parent allows shared runners and setting to true' do + let(:parent) { create(:group, shared_runners_enabled: true) } + let(:group) { build(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) } - it 'is valid' do - expect(group).to be_valid + it 'is valid' do + expect(group).to be_valid + end end - end - context 'when parent allows shared runners and setting to false' do - let(:parent) { create(:group, shared_runners_enabled: true) } - let(:group) { build(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent_id: parent.id) } + context 'when parent allows shared runners and setting to false' do + let(:parent) { create(:group, shared_runners_enabled: true) } + let(:group) { build(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent_id: parent.id) } - it 'is valid' do - expect(group).to be_valid + it 'is valid' do + expect(group).to be_valid + end end end end + + it_behaves_like 'validations called by different namespace types', :changing_allow_descendants_override_disabled_shared_runners_is_allowed end describe '#root?' do diff --git a/spec/models/namespaces/project_namespace_spec.rb b/spec/models/namespaces/project_namespace_spec.rb index f38e8aa85d0..4416c49f1bf 100644 --- a/spec/models/namespaces/project_namespace_spec.rb +++ b/spec/models/namespaces/project_namespace_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Namespaces::ProjectNamespace, type: :model do # using delete rather than destroy due to `delete` skipping AR hooks/callbacks # so it's ensured to work at the DB level. Uses ON DELETE CASCADE on foreign key let_it_be(:project) { create(:project) } - let_it_be(:project_namespace) { create(:project_namespace, project: project) } + let_it_be(:project_namespace) { project.project_namespace } it 'also deletes the associated project' do project_namespace.delete diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 0dd77967f25..9d9cca0678a 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -155,7 +155,7 @@ RSpec.describe Note do expect(note).to receive(:notify_after_destroy).and_call_original expect(note.noteable).to receive(:after_note_destroyed).with(note) - note.destroy + note.destroy! end it 'does not error if noteable is nil' do @@ -163,7 +163,7 @@ RSpec.describe Note do expect(note).to receive(:notify_after_destroy).and_call_original expect(note).to receive(:noteable).at_least(:once).and_return(nil) - expect { note.destroy }.not_to raise_error + expect { note.destroy! }.not_to raise_error end end end @@ -226,8 +226,8 @@ RSpec.describe Note do describe 'read' do before do - @p1.project_members.create(user: @u2, access_level: ProjectMember::GUEST) - @p2.project_members.create(user: @u3, access_level: ProjectMember::GUEST) + @p1.project_members.create!(user: @u2, access_level: ProjectMember::GUEST) + @p2.project_members.create!(user: @u3, access_level: ProjectMember::GUEST) end it { expect(Ability.allowed?(@u1, :read_note, @p1)).to be_falsey } @@ -237,8 +237,8 @@ RSpec.describe Note do describe 'write' do before do - @p1.project_members.create(user: @u2, access_level: ProjectMember::DEVELOPER) - @p2.project_members.create(user: @u3, access_level: ProjectMember::DEVELOPER) + @p1.project_members.create!(user: @u2, access_level: ProjectMember::DEVELOPER) + @p2.project_members.create!(user: @u3, access_level: ProjectMember::DEVELOPER) end it { expect(Ability.allowed?(@u1, :create_note, @p1)).to be_falsey } @@ -248,9 +248,9 @@ RSpec.describe Note do describe 'admin' do before do - @p1.project_members.create(user: @u1, access_level: ProjectMember::REPORTER) - @p1.project_members.create(user: @u2, access_level: ProjectMember::MAINTAINER) - @p2.project_members.create(user: @u3, access_level: ProjectMember::MAINTAINER) + @p1.project_members.create!(user: @u1, access_level: ProjectMember::REPORTER) + @p1.project_members.create!(user: @u2, access_level: ProjectMember::MAINTAINER) + @p2.project_members.create!(user: @u3, access_level: ProjectMember::MAINTAINER) end it { expect(Ability.allowed?(@u1, :admin_note, @p1)).to be_falsey } @@ -1468,7 +1468,7 @@ RSpec.describe Note do shared_examples 'assignee check' do context 'when the provided user is one of the assignees' do before do - note.noteable.update(assignees: [user, create(:user)]) + note.noteable.update!(assignees: [user, create(:user)]) end it 'returns true' do @@ -1480,7 +1480,7 @@ RSpec.describe Note do shared_examples 'author check' do context 'when the provided user is the author' do before do - note.noteable.update(author: user) + note.noteable.update!(author: user) end it 'returns true' do diff --git a/spec/models/notification_setting_spec.rb b/spec/models/notification_setting_spec.rb index 3f1684327e7..cc601fb30c2 100644 --- a/spec/models/notification_setting_spec.rb +++ b/spec/models/notification_setting_spec.rb @@ -36,7 +36,7 @@ RSpec.describe NotificationSetting do notification_setting.merge_merge_request = "t" notification_setting.close_merge_request = "nil" notification_setting.reopen_merge_request = "false" - notification_setting.save + notification_setting.save! end it "parses boolean before saving" do @@ -52,12 +52,12 @@ RSpec.describe NotificationSetting do context 'notification_email' do let_it_be(:user) { create(:user) } - subject { described_class.new(source_id: 1, source_type: 'Project', user_id: user.id) } + subject { build(:notification_setting, user_id: user.id) } it 'allows to change email to verified one' do email = create(:email, :confirmed, user: user) - subject.update(notification_email: email.email) + subject.notification_email = email.email expect(subject).to be_valid end @@ -65,13 +65,13 @@ RSpec.describe NotificationSetting do it 'does not allow to change email to not verified one' do email = create(:email, user: user) - subject.update(notification_email: email.email) + subject.notification_email = email.email expect(subject).to be_invalid end it 'allows to change email to empty one' do - subject.update(notification_email: '') + subject.notification_email = '' expect(subject).to be_valid end @@ -85,7 +85,7 @@ RSpec.describe NotificationSetting do 1.upto(4) do |i| setting = create(:notification_setting, user: user) - setting.project.update(pending_delete: true) if i.even? + setting.project.update!(pending_delete: true) if i.even? end end diff --git a/spec/models/operations/feature_flags/strategy_spec.rb b/spec/models/operations/feature_flags/strategy_spec.rb index 9289e3beab5..de1b9d2c855 100644 --- a/spec/models/operations/feature_flags/strategy_spec.rb +++ b/spec/models/operations/feature_flags/strategy_spec.rb @@ -20,8 +20,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'skips parameters validation' do - strategy = described_class.create(feature_flag: feature_flag, - name: invalid_name, parameters: { bad: 'params' }) + strategy = build(:operations_strategy, + feature_flag: feature_flag, + name: invalid_name, + parameters: { bad: 'params' }) + + expect(strategy).to be_invalid expect(strategy.errors[:name]).to eq(['strategy name is invalid']) expect(strategy.errors[:parameters]).to be_empty @@ -36,19 +40,24 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'must have valid parameters for the strategy' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gradualRolloutUserId', parameters: invalid_parameters) + strategy = build(:operations_strategy, + :gradual_rollout, + feature_flag: feature_flag, + parameters: invalid_parameters) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) end end it 'allows the parameters in any order' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gradualRolloutUserId', - parameters: { percentage: '10', groupId: 'mygroup' }) + strategy = build(:operations_strategy, + :gradual_rollout, + feature_flag: feature_flag, + parameters: { percentage: '10', groupId: 'mygroup' }) - expect(strategy.errors[:parameters]).to be_empty + expect(strategy).to be_valid end describe 'percentage' do @@ -59,9 +68,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gradualRolloutUserId', - parameters: { groupId: 'mygroup', percentage: invalid_value }) + strategy = build(:operations_strategy, + :gradual_rollout, + feature_flag: feature_flag, + parameters: { groupId: 'mygroup', percentage: invalid_value }) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq(['percentage must be a string between 0 and 100 inclusive']) end @@ -72,11 +84,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gradualRolloutUserId', - parameters: { groupId: 'mygroup', percentage: valid_value }) + strategy = build(:operations_strategy, + :gradual_rollout, + feature_flag: feature_flag, + parameters: { groupId: 'mygroup', percentage: valid_value }) - expect(strategy.errors[:parameters]).to eq([]) + expect(strategy).to be_valid end end end @@ -88,9 +101,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'must be a string value of up to 32 lowercase characters' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gradualRolloutUserId', - parameters: { groupId: invalid_value, percentage: '40' }) + strategy = build(:operations_strategy, + :gradual_rollout, + feature_flag: feature_flag, + parameters: { groupId: invalid_value, percentage: '40' }) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq(['groupId parameter is invalid']) end @@ -101,11 +117,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'must be a string value of up to 32 lowercase characters' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gradualRolloutUserId', - parameters: { groupId: valid_value, percentage: '40' }) + strategy = build(:operations_strategy, + :gradual_rollout, + feature_flag: feature_flag, + parameters: { groupId: valid_value, percentage: '40' }) - expect(strategy.errors[:parameters]).to eq([]) + expect(strategy).to be_valid end end end @@ -123,9 +140,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do ]) with_them do it 'must have valid parameters for the strategy' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: invalid_parameters) + strategy = build(:operations_strategy, + :flexible_rollout, + feature_flag: feature_flag, + parameters: invalid_parameters) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) end @@ -137,11 +157,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do [:groupId, 'mygroup'] ].permutation(3).each do |parameters| it "allows the parameters in the order #{parameters.map { |p| p.first }.join(', ')}" do - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: Hash[parameters]) + strategy = build(:operations_strategy, + :flexible_rollout, + feature_flag: feature_flag, + parameters: Hash[parameters]) - expect(strategy.errors[:parameters]).to be_empty + expect(strategy).to be_valid end end @@ -152,9 +173,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do with_them do it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do parameters = { stickiness: 'default', groupId: 'mygroup', rollout: invalid_value } - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: parameters) + strategy = build(:operations_strategy, + :flexible_rollout, + feature_flag: feature_flag, + parameters: parameters) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq([ 'rollout must be a string between 0 and 100 inclusive' @@ -166,11 +190,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do with_them do it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do parameters = { stickiness: 'default', groupId: 'mygroup', rollout: valid_value } - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: parameters) + strategy = build(:operations_strategy, + :flexible_rollout, + feature_flag: feature_flag, + parameters: parameters) - expect(strategy.errors[:parameters]).to eq([]) + expect(strategy).to be_valid end end end @@ -181,9 +206,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do with_them do it 'must be a string value of up to 32 lowercase characters' do parameters = { stickiness: 'default', groupId: invalid_value, rollout: '40' } - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: parameters) + strategy = build(:operations_strategy, + :flexible_rollout, + feature_flag: feature_flag, + parameters: parameters) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq(['groupId parameter is invalid']) end @@ -193,11 +221,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do with_them do it 'must be a string value of up to 32 lowercase characters' do parameters = { stickiness: 'default', groupId: valid_value, rollout: '40' } - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: parameters) + strategy = build(:operations_strategy, + :flexible_rollout, + feature_flag: feature_flag, + parameters: parameters) - expect(strategy.errors[:parameters]).to eq([]) + expect(strategy).to be_valid end end end @@ -207,9 +236,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do with_them do it 'must be a string representing a supported stickiness setting' do parameters = { stickiness: invalid_value, groupId: 'mygroup', rollout: '40' } - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: parameters) + strategy = build(:operations_strategy, + :flexible_rollout, + feature_flag: feature_flag, + parameters: parameters) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq([ 'stickiness parameter must be default, userId, sessionId, or random' @@ -221,11 +253,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do with_them do it 'must be a string representing a supported stickiness setting' do parameters = { stickiness: valid_value, groupId: 'mygroup', rollout: '40' } - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: parameters) + strategy = build(:operations_strategy, + :flexible_rollout, + feature_flag: feature_flag, + parameters: parameters) - expect(strategy.errors[:parameters]).to eq([]) + expect(strategy).to be_valid end end end @@ -237,8 +270,11 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'must have valid parameters for the strategy' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'userWithId', parameters: invalid_parameters) + strategy = build(:operations_strategy, + feature_flag: feature_flag, + name: 'userWithId', parameters: invalid_parameters) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) end @@ -253,10 +289,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'is valid with a string of comma separated values' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'userWithId', parameters: { userIds: valid_value }) + strategy = build(:operations_strategy, + feature_flag: feature_flag, + name: 'userWithId', + parameters: { userIds: valid_value }) - expect(strategy.errors[:parameters]).to be_empty + expect(strategy).to be_valid end end @@ -267,8 +305,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'is invalid' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'userWithId', parameters: { userIds: invalid_value }) + strategy = build(:operations_strategy, + feature_flag: feature_flag, + name: 'userWithId', + parameters: { userIds: invalid_value }) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to include( 'userIds must be a string of unique comma separated values each 256 characters or less' @@ -284,43 +326,48 @@ RSpec.describe Operations::FeatureFlags::Strategy do end with_them do it 'must be empty' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'default', - parameters: invalid_value) + strategy = build(:operations_strategy, :default, feature_flag: feature_flag, parameters: invalid_value) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) end end it 'must be empty' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'default', - parameters: {}) + strategy = build(:operations_strategy, :default, feature_flag: feature_flag) - expect(strategy.errors[:parameters]).to be_empty + expect(strategy).to be_valid end end context 'when the strategy name is gitlabUserList' do + let_it_be(:user_list) { create(:operations_feature_flag_user_list, project: project) } + where(:invalid_value) do [{ groupId: "default", percentage: "7" }, "", "nothing", 7, nil, [], 2.5, { userIds: 'user1' }] end with_them do - it 'must be empty' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gitlabUserList', - parameters: invalid_value) + it 'is invalid' do + strategy = build(:operations_strategy, + :gitlab_userlist, + user_list: user_list, + feature_flag: feature_flag, + parameters: invalid_value) + + expect(strategy).to be_invalid expect(strategy.errors[:parameters]).to eq(['parameters are invalid']) end end - it 'must be empty' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gitlabUserList', - parameters: {}) + it 'is valid' do + strategy = build(:operations_strategy, + :gitlab_userlist, + user_list: user_list, + feature_flag: feature_flag) - expect(strategy.errors[:parameters]).to be_empty + expect(strategy).to be_valid end end end @@ -329,18 +376,15 @@ RSpec.describe Operations::FeatureFlags::Strategy do context 'when name is gitlabUserList' do it 'is valid when associated with a user list' do user_list = create(:operations_feature_flag_user_list, project: project) - strategy = described_class.create(feature_flag: feature_flag, - name: 'gitlabUserList', - user_list: user_list, - parameters: {}) + strategy = build(:operations_strategy, :gitlab_userlist, feature_flag: feature_flag, user_list: user_list) - expect(strategy.errors[:user_list]).to be_empty + expect(strategy).to be_valid end it 'is invalid without a user list' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gitlabUserList', - parameters: {}) + strategy = build(:operations_strategy, :gitlab_userlist, feature_flag: feature_flag, user_list: nil) + + expect(strategy).to be_invalid expect(strategy.errors[:user_list]).to eq(["can't be blank"]) end @@ -348,10 +392,9 @@ RSpec.describe Operations::FeatureFlags::Strategy do it 'is invalid when associated with a user list from another project' do other_project = create(:project) user_list = create(:operations_feature_flag_user_list, project: other_project) - strategy = described_class.create(feature_flag: feature_flag, - name: 'gitlabUserList', - user_list: user_list, - parameters: {}) + strategy = build(:operations_strategy, :gitlab_userlist, feature_flag: feature_flag, user_list: user_list) + + expect(strategy).to be_invalid expect(strategy.errors[:user_list]).to eq(['must belong to the same project']) end @@ -360,84 +403,68 @@ RSpec.describe Operations::FeatureFlags::Strategy do context 'when name is default' do it 'is invalid when associated with a user list' do user_list = create(:operations_feature_flag_user_list, project: project) - strategy = described_class.create(feature_flag: feature_flag, - name: 'default', - user_list: user_list, - parameters: {}) + strategy = build(:operations_strategy, :default, feature_flag: feature_flag, user_list: user_list) + + expect(strategy).to be_invalid expect(strategy.errors[:user_list]).to eq(['must be blank']) end it 'is valid without a user list' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'default', - parameters: {}) + strategy = build(:operations_strategy, :default, feature_flag: feature_flag) - expect(strategy.errors[:user_list]).to be_empty + expect(strategy).to be_valid end end context 'when name is userWithId' do it 'is invalid when associated with a user list' do user_list = create(:operations_feature_flag_user_list, project: project) - strategy = described_class.create(feature_flag: feature_flag, - name: 'userWithId', - user_list: user_list, - parameters: { userIds: 'user1' }) + strategy = build(:operations_strategy, :userwithid, feature_flag: feature_flag, user_list: user_list) + + expect(strategy).to be_invalid expect(strategy.errors[:user_list]).to eq(['must be blank']) end it 'is valid without a user list' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'userWithId', - parameters: { userIds: 'user1' }) + strategy = build(:operations_strategy, :userwithid, feature_flag: feature_flag) - expect(strategy.errors[:user_list]).to be_empty + expect(strategy).to be_valid end end context 'when name is gradualRolloutUserId' do it 'is invalid when associated with a user list' do user_list = create(:operations_feature_flag_user_list, project: project) - strategy = described_class.create(feature_flag: feature_flag, - name: 'gradualRolloutUserId', - user_list: user_list, - parameters: { groupId: 'default', percentage: '10' }) + strategy = build(:operations_strategy, :gradual_rollout, feature_flag: feature_flag, user_list: user_list) + + expect(strategy).to be_invalid expect(strategy.errors[:user_list]).to eq(['must be blank']) end it 'is valid without a user list' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'gradualRolloutUserId', - parameters: { groupId: 'default', percentage: '10' }) + strategy = build(:operations_strategy, :gradual_rollout, feature_flag: feature_flag) - expect(strategy.errors[:user_list]).to be_empty + expect(strategy).to be_valid end end context 'when name is flexibleRollout' do it 'is invalid when associated with a user list' do user_list = create(:operations_feature_flag_user_list, project: project) - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - user_list: user_list, - parameters: { groupId: 'default', - rollout: '10', - stickiness: 'default' }) + strategy = build(:operations_strategy, :flexible_rollout, feature_flag: feature_flag, user_list: user_list) + + expect(strategy).to be_invalid expect(strategy.errors[:user_list]).to eq(['must be blank']) end it 'is valid without a user list' do - strategy = described_class.create(feature_flag: feature_flag, - name: 'flexibleRollout', - parameters: { groupId: 'default', - rollout: '10', - stickiness: 'default' }) + strategy = build(:operations_strategy, :flexible_rollout, feature_flag: feature_flag) - expect(strategy.errors[:user_list]).to be_empty + expect(strategy).to be_valid end end end diff --git a/spec/models/operations/feature_flags/user_list_spec.rb b/spec/models/operations/feature_flags/user_list_spec.rb index 3a48d3389a3..b2dbebb2c0d 100644 --- a/spec/models/operations/feature_flags/user_list_spec.rb +++ b/spec/models/operations/feature_flags/user_list_spec.rb @@ -20,9 +20,9 @@ RSpec.describe Operations::FeatureFlags::UserList do end with_them do it 'is valid with a string of comma separated values' do - user_list = described_class.create(user_xids: valid_value) + user_list = build(:operations_feature_flag_user_list, user_xids: valid_value) - expect(user_list.errors[:user_xids]).to be_empty + expect(user_list).to be_valid end end @@ -31,9 +31,10 @@ RSpec.describe Operations::FeatureFlags::UserList do end with_them do it 'automatically casts values of other types' do - user_list = described_class.create(user_xids: typecast_value) + user_list = build(:operations_feature_flag_user_list, user_xids: typecast_value) + + expect(user_list).to be_valid - expect(user_list.errors[:user_xids]).to be_empty expect(user_list.user_xids).to eq(typecast_value.to_s) end end @@ -45,7 +46,9 @@ RSpec.describe Operations::FeatureFlags::UserList do end with_them do it 'is invalid' do - user_list = described_class.create(user_xids: invalid_value) + user_list = build(:operations_feature_flag_user_list, user_xids: invalid_value) + + expect(user_list).to be_invalid expect(user_list.errors[:user_xids]).to include( 'user_xids must be a string of unique comma separated values each 256 characters or less' @@ -70,20 +73,20 @@ RSpec.describe Operations::FeatureFlags::UserList do describe '#destroy' do it 'deletes the model if it is not associated with any feature flag strategies' do project = create(:project) - user_list = described_class.create(project: project, name: 'My User List', user_xids: 'user1,user2') + user_list = described_class.create!(project: project, name: 'My User List', user_xids: 'user1,user2') - user_list.destroy + user_list.destroy! expect(described_class.count).to eq(0) end it 'does not delete the model if it is associated with a feature flag strategy' do project = create(:project) - user_list = described_class.create(project: project, name: 'My User List', user_xids: 'user1,user2') + user_list = described_class.create!(project: project, name: 'My User List', user_xids: 'user1,user2') feature_flag = create(:operations_feature_flag, :new_version_flag, project: project) strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'gitlabUserList', user_list: user_list) - user_list.destroy + user_list.destroy # rubocop:disable Rails/SaveBang expect(described_class.count).to eq(1) expect(::Operations::FeatureFlags::StrategyUserList.count).to eq(1) diff --git a/spec/models/packages/npm/metadatum_spec.rb b/spec/models/packages/npm/metadatum_spec.rb new file mode 100644 index 00000000000..ff8cce5310e --- /dev/null +++ b/spec/models/packages/npm/metadatum_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Npm::Metadatum, type: :model do + describe 'relationships' do + it { is_expected.to belong_to(:package).inverse_of(:npm_metadatum) } + end + + describe 'validations' do + describe 'package', :aggregate_failures do + it { is_expected.to validate_presence_of(:package) } + + it 'ensure npm package type' do + metadatum = build(:npm_metadatum) + + metadatum.package = build(:nuget_package) + + expect(metadatum).not_to be_valid + expect(metadatum.errors).to contain_exactly('Package type must be NPM') + end + end + + describe 'package_json', :aggregate_failures do + let(:valid_json) { { 'name' => 'foo', 'version' => 'v1.0', 'dist' => { 'tarball' => 'x', 'shasum' => 'x' } } } + + it { is_expected.to allow_value(valid_json).for(:package_json) } + it { is_expected.to allow_value(valid_json.merge('extra-field': { 'foo': 'bar' })).for(:package_json) } + it { is_expected.to allow_value(with_dist { |dist| dist.merge('extra-field': 'x') }).for(:package_json) } + + %w[name version dist].each do |field| + it { is_expected.not_to allow_value(valid_json.except(field)).for(:package_json) } + end + + %w[tarball shasum].each do |field| + it { is_expected.not_to allow_value(with_dist { |dist| dist.except(field) }).for(:package_json) } + end + + it { is_expected.not_to allow_value({}).for(:package_json) } + + it { is_expected.not_to allow_value(test: 'test' * 10000).for(:package_json) } + + def with_dist + valid_json.tap do |h| + h['dist'] = yield(h['dist']) + end + end + end + end +end diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb index 450656e3e9c..8617793f41d 100644 --- a/spec/models/packages/package_file_spec.rb +++ b/spec/models/packages/package_file_spec.rb @@ -14,7 +14,6 @@ RSpec.describe Packages::PackageFile, type: :model do it { is_expected.to belong_to(:package) } it { is_expected.to have_one(:conan_file_metadatum) } it { is_expected.to have_many(:package_file_build_infos).inverse_of(:package_file) } - it { is_expected.to have_many(:pipelines).through(:package_file_build_infos) } it { is_expected.to have_one(:debian_file_metadatum).inverse_of(:package_file).class_name('Packages::Debian::FileMetadatum') } it { is_expected.to have_one(:helm_file_metadatum).inverse_of(:package_file).class_name('Packages::Helm::FileMetadatum') } end @@ -206,6 +205,28 @@ RSpec.describe Packages::PackageFile, type: :model do end end + describe '#pipelines' do + let_it_be_with_refind(:package_file) { create(:package_file) } + + subject { package_file.pipelines } + + context 'package_file without pipeline' do + it { is_expected.to be_empty } + end + + context 'package_file with pipeline' do + let_it_be(:pipeline) { create(:ci_pipeline) } + let_it_be(:pipeline2) { create(:ci_pipeline) } + + before do + package_file.package_file_build_infos.create!(pipeline: pipeline) + package_file.package_file_build_infos.create!(pipeline: pipeline2) + end + + it { is_expected.to contain_exactly(pipeline, pipeline2) } + end + end + describe '#update_file_store callback' do let_it_be(:package_file) { build(:package_file, :nuget, size: nil) } diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 2573c01d686..6ee5219819c 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -14,13 +14,13 @@ RSpec.describe Packages::Package, type: :model do it { is_expected.to have_many(:dependency_links).inverse_of(:package) } it { is_expected.to have_many(:tags).inverse_of(:package) } it { is_expected.to have_many(:build_infos).inverse_of(:package) } - it { is_expected.to have_many(:pipelines).through(:build_infos) } it { is_expected.to have_one(:conan_metadatum).inverse_of(:package) } it { is_expected.to have_one(:maven_metadatum).inverse_of(:package) } it { is_expected.to have_one(:debian_publication).inverse_of(:package).class_name('Packages::Debian::Publication') } it { is_expected.to have_one(:debian_distribution).through(:debian_publication).source(:distribution).inverse_of(:packages).class_name('Packages::Debian::ProjectDistribution') } it { is_expected.to have_one(:nuget_metadatum).inverse_of(:package) } it { is_expected.to have_one(:rubygems_metadatum).inverse_of(:package) } + it { is_expected.to have_one(:npm_metadatum).inverse_of(:package) } end describe '.with_debian_codename' do @@ -999,6 +999,28 @@ RSpec.describe Packages::Package, type: :model do end end + describe '#pipelines' do + let_it_be_with_refind(:package) { create(:maven_package) } + + subject { package.pipelines } + + context 'package without pipeline' do + it { is_expected.to be_empty } + end + + context 'package with pipeline' do + let_it_be(:pipeline) { create(:ci_pipeline) } + let_it_be(:pipeline2) { create(:ci_pipeline) } + + before do + package.build_infos.create!(pipeline: pipeline) + package.build_infos.create!(pipeline: pipeline2) + end + + it { is_expected.to contain_exactly(pipeline, pipeline2) } + end + end + describe '#tag_names' do let_it_be(:package) { create(:nuget_package) } diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index 2b6ed9a9927..d476e18a72c 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -104,7 +104,7 @@ RSpec.describe PagesDomain do let(:domain) { build(:pages_domain) } it 'saves validity time' do - domain.save + domain.save! expect(domain.certificate_valid_not_before).to be_like_time(Time.zone.parse("2020-03-16 14:20:34 UTC")) expect(domain.certificate_valid_not_after).to be_like_time(Time.zone.parse("2220-01-28 14:20:34 UTC")) @@ -161,7 +161,7 @@ RSpec.describe PagesDomain do context 'when certificate is already saved' do it "doesn't add error to certificate" do - domain.save(validate: false) + domain.save!(validate: false) domain.valid? diff --git a/spec/models/preloaders/group_policy_preloader_spec.rb b/spec/models/preloaders/group_policy_preloader_spec.rb new file mode 100644 index 00000000000..f6e40d1f033 --- /dev/null +++ b/spec/models/preloaders/group_policy_preloader_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::GroupPolicyPreloader do + let_it_be(:user) { create(:user) } + let_it_be(:root_parent) { create(:group, :private, name: 'root-1', path: 'root-1') } + let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') } + let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer', parent: root_parent) } + let_it_be(:private_developer_group) { create(:group, :private, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') } + let_it_be(:public_maintainer_group) { create(:group, :private, name: 'a public maintainer', path: 'a-public-maintainer') } + + let(:base_groups) { [guest_group, private_maintainer_group, private_developer_group, public_maintainer_group] } + + before_all do + guest_group.add_guest(user) + private_maintainer_group.add_maintainer(user) + private_developer_group.add_developer(user) + public_maintainer_group.add_maintainer(user) + end + + it 'avoids N+1 queries when authorizing a list of groups', :request_store do + preload_groups_for_policy(user) + control = ActiveRecord::QueryRecorder.new { authorize_all_groups(user) } + + new_group1 = create(:group, :private).tap { |group| group.add_maintainer(user) } + new_group2 = create(:group, :private, parent: private_maintainer_group) + + another_root = create(:group, :private, name: 'root-3', path: 'root-3') + new_group3 = create(:group, :private, parent: another_root).tap { |group| group.add_maintainer(user) } + + pristine_groups = Group.where(id: base_groups + [new_group1, new_group2, new_group3]) + + preload_groups_for_policy(user, pristine_groups) + expect { authorize_all_groups(user, pristine_groups) }.not_to exceed_query_limit(control) + end + + def authorize_all_groups(current_user, group_list = base_groups) + group_list.each { |group| current_user.can?(:read_group, group) } + end + + def preload_groups_for_policy(current_user, group_list = base_groups) + described_class.new(group_list, current_user).execute + end +end diff --git a/spec/models/preloaders/group_root_ancestor_preloader_spec.rb b/spec/models/preloaders/group_root_ancestor_preloader_spec.rb new file mode 100644 index 00000000000..0d622e84ef1 --- /dev/null +++ b/spec/models/preloaders/group_root_ancestor_preloader_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::GroupRootAncestorPreloader do + let_it_be(:user) { create(:user) } + let_it_be(:root_parent1) { create(:group, :private, name: 'root-1', path: 'root-1') } + let_it_be(:root_parent2) { create(:group, :private, name: 'root-2', path: 'root-2') } + let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') } + let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer', parent: root_parent1) } + let_it_be(:private_developer_group) { create(:group, :private, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') } + let_it_be(:public_maintainer_group) { create(:group, :private, name: 'a public maintainer', path: 'a-public-maintainer', parent: root_parent2) } + + let(:root_query_regex) { /\ASELECT.+FROM "namespaces" WHERE "namespaces"."id" = \d+/ } + let(:additional_preloads) { [] } + let(:groups) { [guest_group, private_maintainer_group, private_developer_group, public_maintainer_group] } + let(:pristine_groups) { Group.where(id: groups) } + + shared_examples 'executes N matching DB queries' do |expected_query_count, query_method = nil| + it 'executes the specified root_ancestor queries' do + expect do + pristine_groups.each do |group| + root_ancestor = group.root_ancestor + + root_ancestor.public_send(query_method) if query_method.present? + end + end.to make_queries_matching(root_query_regex, expected_query_count) + end + + it 'strong_memoizes the correct root_ancestor' do + pristine_groups.each do |group| + expected_parent_id = group.root_ancestor.id == group.id ? nil : group.root_ancestor.id + + expect(group.parent_id).to eq(expected_parent_id) + end + end + end + + context 'when the preloader is used' do + before do + preload_ancestors + end + + context 'when no additional preloads are provided' do + it_behaves_like 'executes N matching DB queries', 0 + end + + context 'when additional preloads are provided' do + let(:additional_preloads) { [:route] } + let(:root_query_regex) { /\ASELECT.+FROM "routes" WHERE "routes"."source_id" = \d+/ } + + it_behaves_like 'executes N matching DB queries', 0, :full_path + end + end + + context 'when the preloader is not used' do + it_behaves_like 'executes N matching DB queries', 2 + end + + def preload_ancestors + described_class.new(pristine_groups, additional_preloads).execute + end +end diff --git a/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb b/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb index 8144e1ad233..5fc7bfb1f62 100644 --- a/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb +++ b/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb @@ -13,32 +13,47 @@ RSpec.describe Preloaders::UserMaxAccessLevelInGroupsPreloader do shared_examples 'executes N max member permission queries to the DB' do it 'executes the specified max membership queries' do - queries = ActiveRecord::QueryRecorder.new do - groups.each { |group| user.can?(:read_group, group) } - end + expect { groups.each { |group| user.can?(:read_group, group) } }.to make_queries_matching(max_query_regex, expected_query_count) + end - max_queries = queries.log.grep(max_query_regex) + it 'caches the correct access_level for each group' do + groups.each do |group| + access_level_from_db = group.members_with_parents.where(user_id: user.id).group(:user_id).maximum(:access_level)[user.id] || Gitlab::Access::NO_ACCESS + cached_access_level = group.max_member_access_for_user(user) - expect(max_queries.count).to eq(expected_query_count) + expect(cached_access_level).to eq(access_level_from_db) + end end end context 'when the preloader is used', :request_store do - before do - described_class.new(groups, user).execute - end + context 'when user has indirect access to groups' do + let_it_be(:child_maintainer) { create(:group, :private, parent: group1).tap {|g| g.add_maintainer(user)} } + let_it_be(:child_indirect_access) { create(:group, :private, parent: group1) } - it_behaves_like 'executes N max member permission queries to the DB' do - # Will query all groups where the user is not already a member - let(:expected_query_count) { 1 } - end + let(:groups) { [group1, group2, group3, child_maintainer, child_indirect_access] } + + context 'when traversal_ids feature flag is disabled' do + it_behaves_like 'executes N max member permission queries to the DB' do + before do + stub_feature_flags(use_traversal_ids: false) + described_class.new(groups, user).execute + end + + # One query for group with no access and another one per group where the user is not a direct member + let(:expected_query_count) { 2 } + end + end - context 'when user has access but is not a direct member of the group' do - let(:groups) { [group1, group2, group3, create(:group, :private, parent: group1)] } + context 'when traversal_ids feature flag is enabled' do + it_behaves_like 'executes N max member permission queries to the DB' do + before do + stub_feature_flags(use_traversal_ids: true) + described_class.new(groups, user).execute + end - it_behaves_like 'executes N max member permission queries to the DB' do - # One query for group with no access and another one where the user is not a direct member - let(:expected_query_count) { 2 } + let(:expected_query_count) { 0 } + end end end end diff --git a/spec/models/project_authorization_spec.rb b/spec/models/project_authorization_spec.rb index c517fc8be55..58c0ff48b46 100644 --- a/spec/models/project_authorization_spec.rb +++ b/spec/models/project_authorization_spec.rb @@ -3,9 +3,10 @@ require 'spec_helper' RSpec.describe ProjectAuthorization do - let(:user) { create(:user) } - let(:project1) { create(:project) } - let(:project2) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:project1) { create(:project) } + let_it_be(:project2) { create(:project) } + let_it_be(:project3) { create(:project) } describe '.insert_authorizations' do it 'inserts the authorizations' do @@ -23,5 +24,19 @@ RSpec.describe ProjectAuthorization do expect(user.project_authorizations.count).to eq(2) end + + it 'skips duplicates and inserts the remaining rows without error' do + create(:project_authorization, user: user, project: project1, access_level: Gitlab::Access::MAINTAINER) + + rows = [ + [user.id, project1.id, Gitlab::Access::MAINTAINER], + [user.id, project2.id, Gitlab::Access::MAINTAINER], + [user.id, project3.id, Gitlab::Access::MAINTAINER] + ] + + described_class.insert_authorizations(rows) + + expect(user.project_authorizations.pluck(:user_id, :project_id, :access_level)).to match_array(rows) + end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 2e5c5af4eb0..3a8768ff463 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -16,7 +16,7 @@ RSpec.describe Project, factory_default: :keep do describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:namespace) } - it { is_expected.to belong_to(:project_namespace).class_name('Namespaces::ProjectNamespace').with_foreign_key('project_namespace_id').inverse_of(:project) } + it { is_expected.to belong_to(:project_namespace).class_name('Namespaces::ProjectNamespace').with_foreign_key('project_namespace_id') } it { is_expected.to belong_to(:creator).class_name('User') } it { is_expected.to belong_to(:pool_repository) } it { is_expected.to have_many(:users) } @@ -191,7 +191,7 @@ RSpec.describe Project, factory_default: :keep do # using delete rather than destroy due to `delete` skipping AR hooks/callbacks # so it's ensured to work at the DB level. Uses AFTER DELETE trigger. let_it_be(:project) { create(:project) } - let_it_be(:project_namespace) { create(:project_namespace, project: project) } + let_it_be(:project_namespace) { project.project_namespace } it 'also deletes the associated ProjectNamespace' do project.delete @@ -233,6 +233,58 @@ RSpec.describe Project, factory_default: :keep do expect(project.project_setting).to be_an_instance_of(ProjectSetting) expect(project.project_setting).to be_new_record end + + context 'with project namespaces' do + it 'automatically creates a project namespace' do + project = build(:project, path: 'hopefully-valid-path1') + project.save! + + expect(project).to be_persisted + expect(project.project_namespace).to be_persisted + expect(project.project_namespace).to be_in_sync_with_project(project) + end + + context 'with FF disabled' do + before do + stub_feature_flags(create_project_namespace_on_project_create: false) + end + + it 'does not create a project namespace' do + project = build(:project, path: 'hopefully-valid-path2') + project.save! + + expect(project).to be_persisted + expect(project.project_namespace).to be_nil + end + end + end + end + + context 'updating a project' do + context 'with project namespaces' do + it 'keeps project namespace in sync with project' do + project = create(:project) + project.update!(path: 'hopefully-valid-path1') + + expect(project).to be_persisted + expect(project.project_namespace).to be_persisted + expect(project.project_namespace).to be_in_sync_with_project(project) + end + + context 'with FF disabled' do + before do + stub_feature_flags(create_project_namespace_on_project_create: false) + end + + it 'does not create a project namespace when project is updated' do + project = create(:project) + project.update!(path: 'hopefully-valid-path1') + + expect(project).to be_persisted + expect(project.project_namespace).to be_nil + end + end + end end context 'updating cd_cd_settings' do @@ -294,6 +346,7 @@ RSpec.describe Project, factory_default: :keep do it { is_expected.to validate_presence_of(:namespace) } it { is_expected.to validate_presence_of(:repository_storage) } it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) } + it { is_expected.to validate_length_of(:suggestion_commit_message).is_at_most(255) } it 'validates build timeout constraints' do is_expected.to validate_numericality_of(:build_timeout) @@ -322,6 +375,18 @@ RSpec.describe Project, factory_default: :keep do create(:project) end + context 'validates project namespace creation' do + it 'does not create project namespace if project is not created' do + project = build(:project, path: 'tree') + + project.valid? + + expect(project).not_to be_valid + expect(project).to be_new_record + expect(project.project_namespace).to be_new_record + end + end + context 'repository storages inclusion' do let(:project2) { build(:project, repository_storage: 'missing') } @@ -424,8 +489,9 @@ RSpec.describe Project, factory_default: :keep do end include_context 'invalid urls' + include_context 'valid urls with CRLF' - it 'does not allow urls with CR or LF characters' do + it 'does not allow URLs with unencoded CR or LF characters' do project = build(:project) aggregate_failures do @@ -437,6 +503,19 @@ RSpec.describe Project, factory_default: :keep do end end end + + it 'allow URLs with CR or LF characters' do + project = build(:project) + + aggregate_failures do + valid_urls_with_CRLF.each do |url| + project.import_url = url + + expect(project).to be_valid + expect(project.errors).to be_empty + end + end + end end describe 'project pending deletion' do @@ -1714,13 +1793,19 @@ RSpec.describe Project, factory_default: :keep do allow(::Gitlab::ServiceDeskEmail).to receive(:config).and_return(config) end - it 'returns custom address when project_key is set' do - create(:service_desk_setting, project: project, project_key: 'key1') + context 'when project_key is set' do + it 'returns custom address including the project_key' do + create(:service_desk_setting, project: project, project_key: 'key1') - expect(subject).to eq("foo+#{project.full_path_slug}-key1@bar.com") + expect(subject).to eq("foo+#{project.full_path_slug}-key1@bar.com") + end end - it_behaves_like 'with incoming email address' + context 'when project_key is not set' do + it 'returns custom address including the project full path' do + expect(subject).to eq("foo+#{project.full_path_slug}-#{project.project_id}-issue-@bar.com") + end + end end end @@ -1780,6 +1865,20 @@ RSpec.describe Project, factory_default: :keep do end end + describe '.without_integration' do + it 'returns projects without the integration' do + project_1, project_2, project_3, project_4 = create_list(:project, 4) + instance_integration = create(:jira_integration, :instance) + create(:jira_integration, project: project_1, inherit_from_id: instance_integration.id) + create(:jira_integration, project: project_2, inherit_from_id: nil) + create(:jira_integration, group: create(:group), project: nil, inherit_from_id: nil) + create(:jira_integration, project: project_3, inherit_from_id: nil) + create(:integrations_slack, project: project_4, inherit_from_id: nil) + + expect(Project.without_integration(instance_integration)).to contain_exactly(project_4) + end + end + context 'repository storage by default' do let(:project) { build(:project) } diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb index ead6238b2f4..5fbf1a9c502 100644 --- a/spec/models/project_statistics_spec.rb +++ b/spec/models/project_statistics_spec.rb @@ -325,12 +325,14 @@ RSpec.describe ProjectStatistics do lfs_objects_size: 3, snippets_size: 2, pipeline_artifacts_size: 3, + build_artifacts_size: 3, + packages_size: 6, uploads_size: 5 ) statistics.reload - expect(statistics.storage_size).to eq 19 + expect(statistics.storage_size).to eq 28 end it 'works during wiki_size backfill' do diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 8eab50abd8c..a6a56180ce1 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -234,6 +234,20 @@ RSpec.describe ProjectTeam do expect(project.team.reporter?(user1)).to be(true) expect(project.team.reporter?(user2)).to be(true) end + + context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do + before do + stub_experiments(invite_members_for_task: true) + project.team.add_users([user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: project.id) + end + + it 'creates a member_task with the correct attributes', :aggregate_failures do + member = project.project_members.last + + expect(member.tasks_to_be_done).to match_array([:ci, :code]) + expect(member.member_task.project).to eq(project) + end + end end describe '#add_user' do diff --git a/spec/models/protectable_dropdown_spec.rb b/spec/models/protectable_dropdown_spec.rb index 918c3078405..ab3f455fe63 100644 --- a/spec/models/protectable_dropdown_spec.rb +++ b/spec/models/protectable_dropdown_spec.rb @@ -16,7 +16,7 @@ RSpec.describe ProtectableDropdown do describe '#protectable_ref_names' do context 'when project repository is not empty' do before do - project.protected_branches.create(name: 'master') + create(:protected_branch, project: project, name: 'master') end it { expect(subject.protectable_ref_names).to include('feature') } diff --git a/spec/models/redirect_route_spec.rb b/spec/models/redirect_route_spec.rb index c6e35923b89..2de662fd4b4 100644 --- a/spec/models/redirect_route_spec.rb +++ b/spec/models/redirect_route_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe RedirectRoute do let(:group) { create(:group) } - let!(:redirect_route) { group.redirect_routes.create(path: 'gitlabb') } + let!(:redirect_route) { group.redirect_routes.create!(path: 'gitlabb') } describe 'relationships' do it { is_expected.to belong_to(:source) } @@ -17,10 +17,10 @@ RSpec.describe RedirectRoute do end describe '.matching_path_and_descendants' do - let!(:redirect2) { group.redirect_routes.create(path: 'gitlabb/test') } - let!(:redirect3) { group.redirect_routes.create(path: 'gitlabb/test/foo') } - let!(:redirect4) { group.redirect_routes.create(path: 'gitlabb/test/foo/bar') } - let!(:redirect5) { group.redirect_routes.create(path: 'gitlabb/test/baz') } + let!(:redirect2) { group.redirect_routes.create!(path: 'gitlabb/test') } + let!(:redirect3) { group.redirect_routes.create!(path: 'gitlabb/test/foo') } + let!(:redirect4) { group.redirect_routes.create!(path: 'gitlabb/test/foo/bar') } + let!(:redirect5) { group.redirect_routes.create!(path: 'gitlabb/test/baz') } context 'when the redirect route matches with same casing' do it 'returns correct routes' do diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb index b88813b3328..125fec61d72 100644 --- a/spec/models/release_spec.rb +++ b/spec/models/release_spec.rb @@ -26,10 +26,10 @@ RSpec.describe Release do context 'when a release exists in the database without a name' do it 'does not require name' do existing_release_without_name = build(:release, project: project, author: user, name: nil) - existing_release_without_name.save(validate: false) + existing_release_without_name.save!(validate: false) existing_release_without_name.description = "change" - existing_release_without_name.save + existing_release_without_name.save! existing_release_without_name.reload expect(existing_release_without_name).to be_valid @@ -88,7 +88,7 @@ RSpec.describe Release do describe '.create' do it "fills released_at using created_at if it's not set" do - release = described_class.create(project: project, author: user) + release = create(:release, project: project, author: user, released_at: nil) expect(release.released_at).to eq(release.created_at) end @@ -96,14 +96,14 @@ RSpec.describe Release do it "does not change released_at if it's set explicitly" do released_at = Time.zone.parse('2018-10-20T18:00:00Z') - release = described_class.create(project: project, author: user, released_at: released_at) + release = create(:release, project: project, author: user, released_at: released_at) expect(release.released_at).to eq(released_at) end end describe '#update' do - subject { release.update(params) } + subject { release.update!(params) } context 'when links do not exist' do context 'when params are specified for creation' do @@ -182,7 +182,7 @@ RSpec.describe Release do it 'also deletes the associated evidence' do release_with_evidence - expect { release_with_evidence.destroy }.to change(Releases::Evidence, :count).by(-1) + expect { release_with_evidence.destroy! }.to change(Releases::Evidence, :count).by(-1) end end end @@ -190,7 +190,7 @@ RSpec.describe Release do describe '#name' do context 'name is nil' do before do - release.update(name: nil) + release.update!(name: nil) end it 'returns tag' do diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb index 382359ccb17..9f1d1c84da3 100644 --- a/spec/models/remote_mirror_spec.rb +++ b/spec/models/remote_mirror_spec.rb @@ -289,7 +289,7 @@ RSpec.describe RemoteMirror, :mailer do context 'with remote mirroring disabled' do it 'returns nil' do - remote_mirror.update(enabled: false) + remote_mirror.update!(enabled: false) expect(remote_mirror.sync).to be_nil end @@ -354,7 +354,7 @@ RSpec.describe RemoteMirror, :mailer do let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first } it 'resets all the columns when URL changes' do - remote_mirror.update(last_error: Time.current, + remote_mirror.update!(last_error: Time.current, last_update_at: Time.current, last_successful_update_at: Time.current, update_status: 'started', @@ -378,7 +378,7 @@ RSpec.describe RemoteMirror, :mailer do end before do - remote_mirror.update(last_update_started_at: Time.current) + remote_mirror.update!(last_update_started_at: Time.current) end context 'when remote mirror does not have status failed' do @@ -393,7 +393,7 @@ RSpec.describe RemoteMirror, :mailer do context 'when remote mirror has status failed' do it 'returns false when last update started after the timestamp' do - remote_mirror.update(update_status: 'failed') + remote_mirror.update!(update_status: 'failed') expect(remote_mirror.updated_since?(timestamp)).to be false end @@ -409,7 +409,7 @@ RSpec.describe RemoteMirror, :mailer do updated_at: 25.hours.ago) project = mirror.project project.pending_delete = true - project.save + project.save! mirror.reload expect(mirror.sync).to be_nil diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 7bad907cf90..d50c60774b4 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -66,35 +66,58 @@ RSpec.describe Repository do it { is_expected.not_to include('v1.0.0') } end - describe 'tags_sorted_by' do + describe '#tags_sorted_by' do let(:tags_to_compare) { %w[v1.0.0 v1.1.0] } - let(:feature_flag) { true } - - before do - stub_feature_flags(tags_finder_gitaly: feature_flag) - end context 'name_desc' do subject { repository.tags_sorted_by('name_desc').map(&:name) & tags_to_compare } it { is_expected.to eq(['v1.1.0', 'v1.0.0']) } - - context 'when feature flag is disabled' do - let(:feature_flag) { false } - - it { is_expected.to eq(['v1.1.0', 'v1.0.0']) } - end end context 'name_asc' do - subject { repository.tags_sorted_by('name_asc').map(&:name) & tags_to_compare } + subject { repository.tags_sorted_by('name_asc', pagination_params).map(&:name) & tags_to_compare } + + let(:pagination_params) { nil } it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } - context 'when feature flag is disabled' do - let(:feature_flag) { false } + context 'with pagination' do + context 'with limit' do + let(:pagination_params) { { limit: 1 } } + + it { is_expected.to eq(['v1.0.0']) } + end + + context 'with page token and limit' do + let(:pagination_params) { { page_token: 'refs/tags/v1.0.0', limit: 1 } } + + it { is_expected.to eq(['v1.1.0']) } + end - it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } + context 'with page token only' do + let(:pagination_params) { { page_token: 'refs/tags/v1.0.0' } } + + it 'raises an ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context 'with negative limit' do + let(:pagination_params) { { limit: -1 } } + + it 'returns all tags' do + is_expected.to eq(['v1.0.0', 'v1.1.0']) + end + end + + context 'with unknown token' do + let(:pagination_params) { { page_token: 'unknown' } } + + it 'raises an ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end end end @@ -113,24 +136,12 @@ RSpec.describe Repository do subject { repository.tags_sorted_by('updated_desc').map(&:name) & (tags_to_compare + [latest_tag]) } it { is_expected.to eq([latest_tag, 'v1.1.0', 'v1.0.0']) } - - context 'when feature flag is disabled' do - let(:feature_flag) { false } - - it { is_expected.to eq([latest_tag, 'v1.1.0', 'v1.0.0']) } - end end context 'asc' do subject { repository.tags_sorted_by('updated_asc').map(&:name) & (tags_to_compare + [latest_tag]) } it { is_expected.to eq(['v1.0.0', 'v1.1.0', latest_tag]) } - - context 'when feature flag is disabled' do - let(:feature_flag) { false } - - it { is_expected.to eq(['v1.0.0', 'v1.1.0', latest_tag]) } - end end context 'annotated tag pointing to a blob' do @@ -147,12 +158,6 @@ RSpec.describe Repository do it { is_expected.to eq(['v1.0.0', 'v1.1.0', annotated_tag_name]) } - context 'when feature flag is disabled' do - let(:feature_flag) { false } - - it { is_expected.to eq(['v1.0.0', 'v1.1.0', annotated_tag_name]) } - end - after do rugged_repo(repository).tags.delete(annotated_tag_name) end @@ -163,12 +168,6 @@ RSpec.describe Repository do subject { repository.tags_sorted_by('unknown_desc').map(&:name) & tags_to_compare } it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } - - context 'when feature flag is disabled' do - let(:feature_flag) { false } - - it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } - end end end diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index eb81db95cd3..b2fa9c24535 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -31,18 +31,18 @@ RSpec.describe Route do context 'after update' do it 'calls #create_redirect_for_old_path' do expect(route).to receive(:create_redirect_for_old_path) - route.update(path: 'foo') + route.update!(path: 'foo') end it 'calls #delete_conflicting_redirects' do expect(route).to receive(:delete_conflicting_redirects) - route.update(path: 'foo') + route.update!(path: 'foo') end end context 'after create' do it 'calls #delete_conflicting_redirects' do - route.destroy + route.destroy! new_route = described_class.new(source: group, path: group.path) expect(new_route).to receive(:delete_conflicting_redirects) new_route.save! @@ -81,7 +81,7 @@ RSpec.describe Route do context 'path update' do context 'when route name is set' do before do - route.update(path: 'bar') + route.update!(path: 'bar') end it 'updates children routes with new path' do @@ -111,7 +111,7 @@ RSpec.describe Route do let!(:conflicting_redirect3) { route.create_redirect('gitlab-org') } it 'deletes the conflicting redirects' do - route.update(path: 'bar') + route.update!(path: 'bar') expect(RedirectRoute.exists?(path: 'bar/test')).to be_falsey expect(RedirectRoute.exists?(path: 'bar/test/foo')).to be_falsey @@ -122,7 +122,7 @@ RSpec.describe Route do context 'name update' do it 'updates children routes with new path' do - route.update(name: 'bar') + route.update!(name: 'bar') expect(described_class.exists?(name: 'bar')).to be_truthy expect(described_class.exists?(name: 'bar / test')).to be_truthy @@ -134,7 +134,7 @@ RSpec.describe Route do # Note: using `update_columns` to skip all validation and callbacks route.update_columns(name: nil) - expect { route.update(name: 'bar') } + expect { route.update!(name: 'bar') } .to change { route.name }.from(nil).to('bar') end end diff --git a/spec/models/sentry_issue_spec.rb b/spec/models/sentry_issue_spec.rb index c24350d7067..09b23b6fd0d 100644 --- a/spec/models/sentry_issue_spec.rb +++ b/spec/models/sentry_issue_spec.rb @@ -53,7 +53,7 @@ RSpec.describe SentryIssue do create(:sentry_issue) project = sentry_issue.issue.project sentry_issue_3 = build(:sentry_issue, issue: create(:issue, project: project), sentry_issue_identifier: sentry_issue.sentry_issue_identifier) - sentry_issue_3.save(validate: false) + sentry_issue_3.save!(validate: false) result = described_class.for_project_and_identifier(project, sentry_issue.sentry_issue_identifier) diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index 4e20a83f18e..e24dd910c39 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -98,7 +98,7 @@ RSpec.describe Snippet do snippet = build(:snippet) expect(snippet.statistics).to be_nil - snippet.save + snippet.save! expect(snippet.statistics).to be_persisted end @@ -289,7 +289,7 @@ RSpec.describe Snippet do let(:access_level) { ProjectFeature::ENABLED } before do - project.project_feature.update(snippets_access_level: access_level) + project.project_feature.update!(snippets_access_level: access_level) end it 'includes snippets for projects with snippets enabled' do @@ -623,7 +623,7 @@ RSpec.describe Snippet do context 'when snippet_repository does not exist' do it 'creates a snippet_repository' do - snippet.snippet_repository.destroy + snippet.snippet_repository.destroy! snippet.reload expect do diff --git a/spec/models/suggestion_spec.rb b/spec/models/suggestion_spec.rb index 9a7624c253a..4f91908264f 100644 --- a/spec/models/suggestion_spec.rb +++ b/spec/models/suggestion_spec.rb @@ -154,6 +154,14 @@ RSpec.describe Suggestion do it { is_expected.to eq("This suggestion already matches its content.") } end + context 'when file is .ipynb' do + before do + allow(suggestion).to receive(:file_path).and_return("example.ipynb") + end + + it { is_expected.to eq(_("This file was modified for readability, and can't accept suggestions. Edit it directly.")) } + end + context 'when applicable' do it { is_expected.to be_nil } end diff --git a/spec/models/u2f_registration_spec.rb b/spec/models/u2f_registration_spec.rb index aba2f27d104..7a70cf69566 100644 --- a/spec/models/u2f_registration_spec.rb +++ b/spec/models/u2f_registration_spec.rb @@ -5,9 +5,11 @@ require 'spec_helper' RSpec.describe U2fRegistration do let_it_be(:user) { create(:user) } + let(:u2f_registration_name) { 'u2f_device' } + let(:u2f_registration) do device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5)) - create(:u2f_registration, name: 'u2f_device', + create(:u2f_registration, name: u2f_registration_name, user: user, certificate: Base64.strict_encode64(device.cert_raw), key_handle: U2F.urlsafe_encode64(device.key_handle_raw), @@ -16,11 +18,27 @@ RSpec.describe U2fRegistration do describe 'callbacks' do describe '#create_webauthn_registration' do - it 'creates webauthn registration' do - u2f_registration.save! + shared_examples_for 'creates webauthn registration' do + it 'creates webauthn registration' do + u2f_registration.save! + + webauthn_registration = WebauthnRegistration.where(u2f_registration_id: u2f_registration.id) + expect(webauthn_registration).to exist + end + end + + it_behaves_like 'creates webauthn registration' + + context 'when the u2f_registration has a blank name' do + let(:u2f_registration_name) { '' } + + it_behaves_like 'creates webauthn registration' + end + + context 'when the u2f_registration has the name as `nil`' do + let(:u2f_registration_name) { nil } - webauthn_registration = WebauthnRegistration.where(u2f_registration_id: u2f_registration.id) - expect(webauthn_registration).to exist + it_behaves_like 'creates webauthn registration' end it 'logs error' do diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index 0ac684cd04c..cdf73b203af 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Upload do it 'schedules checksum calculation' do stub_const('UploadChecksumWorker', spy) - upload = described_class.create( + upload = described_class.create!( path: __FILE__, size: described_class::CHECKSUM_THRESHOLD + 1.kilobyte, model: build_stubbed(:user), @@ -42,7 +42,7 @@ RSpec.describe Upload do store: ObjectStorage::Store::LOCAL ) - expect { upload.save } + expect { upload.save! } .to change { upload.checksum }.from(nil) .to(a_string_matching(/\A\h{64}\z/)) end @@ -55,7 +55,7 @@ RSpec.describe Upload do it 'calls delete_file!' do is_expected.to receive(:delete_file!) - subject.destroy + subject.destroy! end end end @@ -82,6 +82,18 @@ RSpec.describe Upload do end end + describe '#relative_path' do + it "delegates to the uploader's relative_path method" do + uploader = spy('FakeUploader') + upload = described_class.new(path: '/tmp/secret/file.jpg', store: ObjectStorage::Store::LOCAL) + expect(upload).to receive(:uploader_class).and_return(uploader) + + upload.relative_path + + expect(uploader).to have_received(:relative_path).with(upload) + end + end + describe '#calculate_checksum!' do let(:upload) do described_class.new(path: __FILE__, diff --git a/spec/models/uploads/fog_spec.rb b/spec/models/uploads/fog_spec.rb index 899e6f2064c..1ffe7c6c43b 100644 --- a/spec/models/uploads/fog_spec.rb +++ b/spec/models/uploads/fog_spec.rb @@ -40,7 +40,9 @@ RSpec.describe Uploads::Fog do end describe '#delete_keys' do + let(:connection) { ::Fog::Storage.new(FileUploader.object_store_credentials) } let(:keys) { data_store.keys(relation) } + let(:paths) { relation.pluck(:path) } let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) } subject { data_store.delete_keys(keys) } @@ -50,17 +52,32 @@ RSpec.describe Uploads::Fog do end it 'deletes multiple data' do - paths = relation.pluck(:path) + paths.each do |path| + expect(connection.get_object('uploads', path)[:body]).not_to be_nil + end + + subject + + paths.each do |path| + expect { connection.get_object('uploads', path)[:body] }.to raise_error(Excon::Error::NotFound) + end + end - ::Fog::Storage.new(FileUploader.object_store_credentials).tap do |connection| + context 'when one of keys is missing' do + let(:keys) { ['unknown'] + super() } + + it 'deletes only existing keys' do paths.each do |path| expect(connection.get_object('uploads', path)[:body]).not_to be_nil end - end - subject + expect_next_instance_of(::Fog::Storage) do |storage| + allow(storage).to receive(:delete_object).and_call_original + expect(storage).to receive(:delete_object).with('uploads', keys.first).and_raise(::Google::Apis::ClientError, 'NotFound') + end + + subject - ::Fog::Storage.new(FileUploader.object_store_credentials).tap do |connection| paths.each do |path| expect { connection.get_object('uploads', path)[:body] }.to raise_error(Excon::Error::NotFound) end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 21c5aea514a..b5d4614d206 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -6,6 +6,7 @@ RSpec.describe User do include ProjectForksHelper include TermsHelper include ExclusiveLeaseHelpers + include LdapHelpers it_behaves_like 'having unique enum values' @@ -98,7 +99,7 @@ RSpec.describe User do it { is_expected.to have_many(:group_members) } it { is_expected.to have_many(:groups) } it { is_expected.to have_many(:keys).dependent(:destroy) } - it { is_expected.to have_many(:expired_and_unnotified_keys) } + it { is_expected.to have_many(:expired_today_and_unnotified_keys) } it { is_expected.to have_many(:deploy_keys).dependent(:nullify) } it { is_expected.to have_many(:group_deploy_keys) } it { is_expected.to have_many(:events).dependent(:delete_all) } @@ -1123,7 +1124,7 @@ RSpec.describe User do end describe 'after commit hook' do - describe '#update_emails_with_primary_email' do + describe 'when the primary email is updated' do before do @user = create(:user, email: 'primary@example.com').tap do |user| user.skip_reconfirmation! @@ -1132,13 +1133,7 @@ RSpec.describe User do @user.reload end - it 'gets called when email updated' do - expect(@user).to receive(:update_emails_with_primary_email) - - @user.update!(email: 'new_primary@example.com') - end - - it 'adds old primary to secondary emails when secondary is a new email' do + it 'keeps old primary to secondary emails when secondary is a new email' do @user.update!(email: 'new_primary@example.com') @user.reload @@ -1146,22 +1141,6 @@ RSpec.describe User do expect(@user.emails.pluck(:email)).to match_array([@secondary.email, 'primary@example.com']) end - it 'adds old primary to secondary emails if secondary is becoming a primary' do - @user.update!(email: @secondary.email) - @user.reload - - expect(@user.emails.count).to eq 1 - expect(@user.emails.first.email).to eq 'primary@example.com' - end - - it 'transfers old confirmation values into new secondary' do - @user.update!(email: @secondary.email) - @user.reload - - expect(@user.emails.count).to eq 1 - expect(@user.emails.first.confirmed_at).not_to eq nil - end - context 'when the first email was unconfirmed and the second email gets confirmed' do let(:user) { create(:user, :unconfirmed, email: 'should-be-unconfirmed@test.com') } @@ -1178,11 +1157,8 @@ RSpec.describe User do expect(user).to be_confirmed end - it 'keeps the unconfirmed email unconfirmed' do - email = user.emails.first - - expect(email.email).to eq('should-be-unconfirmed@test.com') - expect(email).not_to be_confirmed + it 'does not add unconfirmed email to secondary' do + expect(user.emails.map(&:email)).not_to include('should-be-unconfirmed@test.com') end it 'has only one email association' do @@ -1244,7 +1220,7 @@ RSpec.describe User do expect(user.email).to eq(confirmed_email) end - it 'moves the old email' do + it 'keeps the old email' do email = user.reload.emails.first expect(email.email).to eq(old_confirmed_email) @@ -1499,7 +1475,7 @@ RSpec.describe User do allow_any_instance_of(ApplicationSetting).to receive(:send_user_confirmation_email).and_return(true) end - let(:user) { create(:user, confirmed_at: nil, unconfirmed_email: 'test@gitlab.com') } + let(:user) { create(:user, :unconfirmed, unconfirmed_email: 'test@gitlab.com') } it 'returns unconfirmed' do expect(user.confirmed?).to be_falsey @@ -1509,6 +1485,22 @@ RSpec.describe User do user.confirm expect(user.confirmed?).to be_truthy end + + it 'adds the confirmed primary email to emails' do + expect(user.emails.confirmed.map(&:email)).not_to include(user.email) + + user.confirm + + expect(user.emails.confirmed.map(&:email)).to include(user.email) + end + end + + context 'if the user is created with confirmed_at set to a time' do + let!(:user) { create(:user, email: 'test@gitlab.com', confirmed_at: Time.now.utc) } + + it 'adds the confirmed primary email to emails upon creation' do + expect(user.emails.confirmed.map(&:email)).to include(user.email) + end end describe '#to_reference' do @@ -2216,7 +2208,7 @@ RSpec.describe User do end context 'primary email not confirmed' do - let(:user) { create(:user, confirmed_at: nil) } + let(:user) { create(:user, :unconfirmed) } let!(:email) { create(:email, :confirmed, user: user, email: 'foo@example.com') } it 'finds user respecting the confirmed flag' do @@ -2231,7 +2223,7 @@ RSpec.describe User do end it 'returns nil when user is not confirmed' do - user = create(:user, email: 'foo@example.com', confirmed_at: nil) + user = create(:user, :unconfirmed, email: 'foo@example.com') expect(described_class.find_by_any_email(user.email, confirmed: false)).to eq(user) expect(described_class.find_by_any_email(user.email, confirmed: true)).to be_nil @@ -4155,6 +4147,23 @@ RSpec.describe User do end end + describe '#remove_project_authorizations' do + let_it_be(:project1) { create(:project) } + let_it_be(:project2) { create(:project) } + let_it_be(:project3) { create(:project) } + let_it_be(:user) { create(:user) } + + it 'removes the project authorizations of the user, in specified projects' do + create(:project_authorization, user: user, project: project1) + create(:project_authorization, user: user, project: project2) + create(:project_authorization, user: user, project: project3) + + user.remove_project_authorizations([project1.id, project2.id]) + + expect(user.project_authorizations.pluck(:project_id)).to match_array([project3.id]) + end + end + describe '#access_level=' do let(:user) { build(:user) } @@ -5817,7 +5826,7 @@ RSpec.describe User do end describe '#active_for_authentication?' do - subject { user.active_for_authentication? } + subject(:active_for_authentication?) { user.active_for_authentication? } let(:user) { create(:user) } @@ -5827,6 +5836,14 @@ RSpec.describe User do end it { is_expected.to be false } + + it 'does not check if LDAP is allowed' do + stub_ldap_setting(enabled: true) + + expect(Gitlab::Auth::Ldap::Access).not_to receive(:allowed?) + + active_for_authentication? + end end context 'when user is a ghost user' do @@ -5837,6 +5854,28 @@ RSpec.describe User do it { is_expected.to be false } end + context 'when user is ldap_blocked' do + before do + user.ldap_block + end + + it 'rechecks if LDAP is allowed when LDAP is enabled' do + stub_ldap_setting(enabled: true) + + expect(Gitlab::Auth::Ldap::Access).to receive(:allowed?) + + active_for_authentication? + end + + it 'does not check if LDAP is allowed when LDAP is not enabled' do + stub_ldap_setting(enabled: false) + + expect(Gitlab::Auth::Ldap::Access).not_to receive(:allowed?) + + active_for_authentication? + end + end + context 'based on user type' do using RSpec::Parameterized::TableSyntax @@ -6011,7 +6050,7 @@ RSpec.describe User do subject { user.confirmation_required_on_sign_in? } context 'when user is confirmed' do - let(:user) { build_stubbed(:user) } + let(:user) { create(:user) } it 'is falsey' do expect(user.confirmed?).to be_truthy @@ -6203,4 +6242,31 @@ RSpec.describe User do expect(described_class.get_ids_by_username([user_name])).to match_array([user_id]) end end + + describe 'user_project' do + it 'returns users project matched by username and public visibility' do + user = create(:user) + public_project = create(:project, :public, path: user.username, namespace: user.namespace) + create(:project, namespace: user.namespace) + + expect(user.user_project).to eq(public_project) + end + end + + describe 'user_readme' do + it 'returns readme from user project' do + user = create(:user) + create(:project, :repository, :public, path: user.username, namespace: user.namespace) + + expect(user.user_readme.name).to eq('README.md') + expect(user.user_readme.data).to include('testme') + end + + it 'returns nil if project is private' do + user = create(:user) + create(:project, :repository, :private, path: user.username, namespace: user.namespace) + + expect(user.user_readme).to be(nil) + end + end end diff --git a/spec/models/users/credit_card_validation_spec.rb b/spec/models/users/credit_card_validation_spec.rb index d2b4f5ebd65..43edf7ed093 100644 --- a/spec/models/users/credit_card_validation_spec.rb +++ b/spec/models/users/credit_card_validation_spec.rb @@ -6,19 +6,25 @@ RSpec.describe Users::CreditCardValidation do it { is_expected.to belong_to(:user) } it { is_expected.to validate_length_of(:holder_name).is_at_most(26) } + it { is_expected.to validate_length_of(:network).is_at_most(32) } it { is_expected.to validate_numericality_of(:last_digits).is_less_than_or_equal_to(9999) } describe '.similar_records' do - let(:card_details) { subject.attributes.slice(:expiration_date, :last_digits, :holder_name) } + let(:card_details) do + subject.attributes.with_indifferent_access.slice(:expiration_date, :last_digits, :network, :holder_name) + end - subject(:credit_card_validation) { create(:credit_card_validation) } + subject!(:credit_card_validation) { create(:credit_card_validation, holder_name: 'Alice') } let!(:match1) { create(:credit_card_validation, card_details) } - let!(:other1) { create(:credit_card_validation, card_details.merge(last_digits: 9)) } - let!(:match2) { create(:credit_card_validation, card_details) } - let!(:other2) { create(:credit_card_validation, card_details.merge(holder_name: 'foo bar')) } + let!(:match2) { create(:credit_card_validation, card_details.merge(holder_name: 'Bob')) } + let!(:non_match1) { create(:credit_card_validation, card_details.merge(last_digits: 9)) } + let!(:non_match2) { create(:credit_card_validation, card_details.merge(network: 'unknown')) } + let!(:non_match3) do + create(:credit_card_validation, card_details.dup.tap { |h| h[:expiration_date] += 1.year }) + end - it 'returns records with matching credit card, ordered by credit_card_validated_at' do + it 'returns matches with the same last_digits, expiration and network, ordered by credit_card_validated_at' do expect(subject.similar_records).to eq([match2, match1, subject]) end end diff --git a/spec/models/users/in_product_marketing_email_spec.rb b/spec/models/users/in_product_marketing_email_spec.rb index a9ddd86677c..cf08cf7ceed 100644 --- a/spec/models/users/in_product_marketing_email_spec.rb +++ b/spec/models/users/in_product_marketing_email_spec.rb @@ -21,7 +21,8 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do describe '.tracks' do it 'has an entry for every track' do - expect(Namespaces::InProductMarketingEmailsService::TRACKS.keys).to match_array(described_class.tracks.keys.map(&:to_sym)) + tracks = [Namespaces::InviteTeamEmailService::TRACK, Namespaces::InProductMarketingEmailsService::TRACKS.keys].flatten + expect(tracks).to match_array(described_class.tracks.keys.map(&:to_sym)) end end diff --git a/spec/models/users/merge_request_interaction_spec.rb b/spec/models/users/merge_request_interaction_spec.rb index d333577fa1a..12c7fa43a60 100644 --- a/spec/models/users/merge_request_interaction_spec.rb +++ b/spec/models/users/merge_request_interaction_spec.rb @@ -61,7 +61,7 @@ RSpec.describe ::Users::MergeRequestInteraction do merge_request.reviewers << user end - it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['UNREVIEWED'].value) } + it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['ATTENTION_REQUESTED'].value) } it 'implies not reviewed' do expect(interaction).not_to be_reviewed @@ -70,7 +70,8 @@ RSpec.describe ::Users::MergeRequestInteraction do context 'when the user has provided a review' do before do - merge_request.merge_request_reviewers.create!(reviewer: user, state: MergeRequestReviewer.states['reviewed']) + reviewer = merge_request.merge_request_reviewers.create!(reviewer: user) + reviewer.update!(state: MergeRequestReviewer.states['reviewed']) end it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['REVIEWED'].value) } diff --git a/spec/models/users_statistics_spec.rb b/spec/models/users_statistics_spec.rb index b4b7ddb7c63..8553d0bfdb0 100644 --- a/spec/models/users_statistics_spec.rb +++ b/spec/models/users_statistics_spec.rb @@ -34,11 +34,11 @@ RSpec.describe UsersStatistics do describe '.create_current_stats!' do before do - create_list(:user_highest_role, 4) + create_list(:user_highest_role, 1) create_list(:user_highest_role, 2, :guest) - create_list(:user_highest_role, 3, :reporter) - create_list(:user_highest_role, 4, :developer) - create_list(:user_highest_role, 3, :maintainer) + create_list(:user_highest_role, 2, :reporter) + create_list(:user_highest_role, 2, :developer) + create_list(:user_highest_role, 2, :maintainer) create_list(:user_highest_role, 2, :owner) create_list(:user, 2, :bot) create_list(:user, 1, :blocked) @@ -49,11 +49,11 @@ RSpec.describe UsersStatistics do context 'when successful' do it 'creates an entry with the current statistics values' do expect(described_class.create_current_stats!).to have_attributes( - without_groups_and_projects: 4, + without_groups_and_projects: 1, with_highest_role_guest: 2, - with_highest_role_reporter: 3, - with_highest_role_developer: 4, - with_highest_role_maintainer: 3, + with_highest_role_reporter: 2, + with_highest_role_developer: 2, + with_highest_role_maintainer: 2, with_highest_role_owner: 2, bots: 2, blocked: 1 diff --git a/spec/models/webauthn_registration_spec.rb b/spec/models/webauthn_registration_spec.rb new file mode 100644 index 00000000000..6813854bf6c --- /dev/null +++ b/spec/models/webauthn_registration_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebauthnRegistration do + describe 'relations' do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:credential_xid) } + it { is_expected.to validate_presence_of(:public_key) } + it { is_expected.to validate_presence_of(:counter) } + it { is_expected.to validate_length_of(:name).is_at_least(0) } + it { is_expected.not_to allow_value(nil).for(:name) } + it do + is_expected.to validate_numericality_of(:counter) + .only_integer + .is_greater_than_or_equal_to(0) + .is_less_than_or_equal_to(4294967295) + end + end +end diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 201ccf0fc14..fc4fbace790 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -11,8 +11,8 @@ RSpec.describe GroupPolicy do it do expect_allowed(:read_group) - expect_allowed(:read_organization) - expect_allowed(:read_contact) + expect_allowed(:read_crm_organization) + expect_allowed(:read_crm_contact) expect_allowed(:read_counts) expect_allowed(*read_group_permissions) expect_disallowed(:upload_file) @@ -33,8 +33,8 @@ RSpec.describe GroupPolicy do end it { expect_disallowed(:read_group) } - it { expect_disallowed(:read_organization) } - it { expect_disallowed(:read_contact) } + it { expect_disallowed(:read_crm_organization) } + it { expect_disallowed(:read_crm_contact) } it { expect_disallowed(:read_counts) } it { expect_disallowed(*read_group_permissions) } end @@ -48,8 +48,8 @@ RSpec.describe GroupPolicy do end it { expect_disallowed(:read_group) } - it { expect_disallowed(:read_organization) } - it { expect_disallowed(:read_contact) } + it { expect_disallowed(:read_crm_organization) } + it { expect_disallowed(:read_crm_contact) } it { expect_disallowed(:read_counts) } it { expect_disallowed(*read_group_permissions) } end @@ -933,8 +933,8 @@ RSpec.describe GroupPolicy do it { is_expected.to be_allowed(:read_package) } it { is_expected.to be_allowed(:read_group) } - it { is_expected.to be_allowed(:read_organization) } - it { is_expected.to be_allowed(:read_contact) } + it { is_expected.to be_allowed(:read_crm_organization) } + it { is_expected.to be_allowed(:read_crm_contact) } it { is_expected.to be_disallowed(:create_package) } end @@ -944,8 +944,8 @@ RSpec.describe GroupPolicy do it { is_expected.to be_allowed(:create_package) } it { is_expected.to be_allowed(:read_package) } it { is_expected.to be_allowed(:read_group) } - it { is_expected.to be_allowed(:read_organization) } - it { is_expected.to be_allowed(:read_contact) } + it { is_expected.to be_allowed(:read_crm_organization) } + it { is_expected.to be_allowed(:read_crm_contact) } it { is_expected.to be_disallowed(:destroy_package) } end @@ -1032,4 +1032,17 @@ RSpec.describe GroupPolicy do it { is_expected.to be_disallowed(:update_runners_registration_token) } end end + + context 'with customer_relations feature flag disabled' do + let(:current_user) { owner } + + before do + stub_feature_flags(customer_relations: false) + end + + it { is_expected.to be_disallowed(:read_crm_contact) } + it { is_expected.to be_disallowed(:read_crm_organization) } + it { is_expected.to be_disallowed(:admin_crm_contact) } + it { is_expected.to be_disallowed(:admin_crm_organization) } + end end diff --git a/spec/policies/namespaces/project_namespace_policy_spec.rb b/spec/policies/namespaces/project_namespace_policy_spec.rb index 22f3ccec1f8..5bb38deb498 100644 --- a/spec/policies/namespaces/project_namespace_policy_spec.rb +++ b/spec/policies/namespaces/project_namespace_policy_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' RSpec.describe NamespacePolicy do let_it_be(:parent) { create(:namespace) } - let_it_be(:namespace) { create(:project_namespace, parent: parent) } + let_it_be(:project) { create(:project, namespace: parent) } + let_it_be(:namespace) { project.project_namespace } let(:permissions) do [:owner_access, :create_projects, :admin_namespace, :read_namespace, diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index f36b0a62aa3..2953c198af6 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -104,29 +104,71 @@ RSpec.describe ProjectPolicy do end context 'pipeline feature' do - let(:project) { private_project } + let(:project) { private_project } + let(:current_user) { developer } + let(:pipeline) { create(:ci_pipeline, project: project) } - before do - private_project.add_developer(current_user) + describe 'for confirmed user' do + it 'allows modify pipelines' do + expect_allowed(:create_pipeline) + expect_allowed(:update_pipeline) + expect_allowed(:create_pipeline_schedule) + end end describe 'for unconfirmed user' do - let(:current_user) { create(:user, confirmed_at: nil) } + let(:current_user) { project.owner.tap { |u| u.update!(confirmed_at: nil) } } it 'disallows to modify pipelines' do expect_disallowed(:create_pipeline) expect_disallowed(:update_pipeline) + expect_disallowed(:destroy_pipeline) expect_disallowed(:create_pipeline_schedule) end end - describe 'for confirmed user' do - let(:current_user) { developer } + describe 'destroy permission' do + describe 'for developers' do + it 'prevents :destroy_pipeline' do + expect(current_user.can?(:destroy_pipeline, pipeline)).to be_falsey + end + end - it 'allows modify pipelines' do - expect_allowed(:create_pipeline) - expect_allowed(:update_pipeline) - expect_allowed(:create_pipeline_schedule) + describe 'for maintainers' do + let(:current_user) { maintainer } + + it 'prevents :destroy_pipeline' do + project.add_maintainer(maintainer) + expect(current_user.can?(:destroy_pipeline, pipeline)).to be_falsey + end + end + + describe 'for project owner' do + let(:current_user) { project.owner } + + it 'allows :destroy_pipeline' do + expect(current_user.can?(:destroy_pipeline, pipeline)).to be_truthy + end + + context 'on archived projects' do + before do + project.update!(archived: true) + end + + it 'prevents :destroy_pipeline' do + expect(current_user.can?(:destroy_pipeline, pipeline)).to be_falsey + end + end + + context 'on archived pending_delete projects' do + before do + project.update!(archived: true, pending_delete: true) + end + + it 'allows :destroy_pipeline' do + expect(current_user.can?(:destroy_pipeline, pipeline)).to be_truthy + end + end end end end @@ -955,6 +997,28 @@ RSpec.describe ProjectPolicy do end end + context 'infrastructure google cloud feature' do + %w(guest reporter developer).each do |role| + context role do + let(:current_user) { send(role) } + + it 'disallows managing google cloud' do + expect_disallowed(:admin_project_google_cloud) + end + end + end + + %w(maintainer owner).each do |role| + context role do + let(:current_user) { send(role) } + + it 'allows managing google cloud' do + expect_allowed(:admin_project_google_cloud) + end + end + end + end + describe 'design permissions' do include DesignManagementTestHelpers diff --git a/spec/presenters/award_emoji_presenter_spec.rb b/spec/presenters/award_emoji_presenter_spec.rb index 58ee985f165..a23196282a2 100644 --- a/spec/presenters/award_emoji_presenter_spec.rb +++ b/spec/presenters/award_emoji_presenter_spec.rb @@ -6,21 +6,22 @@ RSpec.describe AwardEmojiPresenter do let(:emoji_name) { 'thumbsup' } let(:award_emoji) { build(:award_emoji, name: emoji_name) } let(:presenter) { described_class.new(award_emoji) } + let(:emoji) { TanukiEmoji.find_by_alpha_code(emoji_name) } describe '#description' do - it { expect(presenter.description).to eq Gitlab::Emoji.emojis[emoji_name]['description'] } + it { expect(presenter.description).to eq emoji.description } end describe '#unicode' do - it { expect(presenter.unicode).to eq Gitlab::Emoji.emojis[emoji_name]['unicode'] } + it { expect(presenter.unicode).to eq emoji.hex } end describe '#unicode_version' do - it { expect(presenter.unicode_version).to eq Gitlab::Emoji.emoji_unicode_version(emoji_name) } + it { expect(presenter.unicode_version).to eq('6.0') } end describe '#emoji' do - it { expect(presenter.emoji).to eq Gitlab::Emoji.emojis[emoji_name]['moji'] } + it { expect(presenter.emoji).to eq emoji.codepoints } end describe 'when presenting an award emoji with an invalid name' do diff --git a/spec/presenters/blob_presenter_spec.rb b/spec/presenters/blob_presenter_spec.rb index 466a2b55e76..28e18708eab 100644 --- a/spec/presenters/blob_presenter_spec.rb +++ b/spec/presenters/blob_presenter_spec.rb @@ -31,6 +31,20 @@ RSpec.describe BlobPresenter do it { expect(presenter.replace_path).to eq("/#{project.full_path}/-/create/#{blob.commit_id}/#{blob.path}") } end + describe '#pipeline_editor_path' do + context 'when blob is .gitlab-ci.yml' do + before do + project.repository.create_file(user, '.gitlab-ci.yml', '', + message: 'Add a ci file', + branch_name: 'main') + end + + let(:blob) { repository.blob_at('main', '.gitlab-ci.yml') } + + it { expect(presenter.pipeline_editor_path).to eq("/#{project.full_path}/-/ci/editor?branch_name=#{blob.commit_id}") } + end + end + describe '#ide_edit_path' do it { expect(presenter.ide_edit_path).to eq("/-/ide/project/#{project.full_path}/edit/HEAD/-/files/ruby/regex.rb") } end @@ -121,6 +135,47 @@ RSpec.describe BlobPresenter do end end + describe '#highlight_transformed' do + context 'when blob is ipynb' do + let(:blob) { repository.blob_at('f6b7a707', 'files/ipython/markdown-table.ipynb') } + let(:git_blob) { blob.__getobj__ } + + before do + allow(git_blob).to receive(:transformed_for_diff).and_return(true) + end + + it 'uses md as the transformed language' do + expect(Gitlab::Highlight).to receive(:highlight).with('files/ipython/markdown-table.ipynb', anything, plain: nil, language: 'md') + + presenter.highlight_transformed + end + + it 'transforms the blob' do + expect(Gitlab::Highlight).to receive(:highlight).with('files/ipython/markdown-table.ipynb', include("%%"), plain: nil, language: 'md') + + presenter.highlight_transformed + end + end + + context 'when blob is other file type' do + let(:git_blob) { blob.__getobj__ } + + before do + allow(git_blob) + .to receive(:data) + .and_return("line one\nline two\nline 3") + + allow(blob).to receive(:language_from_gitattributes).and_return('ruby') + end + + it 'does not transform the file' do + expect(Gitlab::Highlight).to receive(:highlight).with('files/ruby/regex.rb', git_blob.data, plain: nil, language: 'ruby') + + presenter.highlight_transformed + end + end + end + describe '#raw_plain_data' do let(:blob) { repository.blob_at('HEAD', file) } diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb index 4422773fec6..b8d0b093a24 100644 --- a/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/spec/presenters/ci/build_runner_presenter_spec.rb @@ -259,12 +259,7 @@ RSpec.describe Ci::BuildRunnerPresenter do describe '#runner_variables' do subject { presenter.runner_variables } - let_it_be(:project_with_flag_disabled) { create(:project, :repository) } - let_it_be(:project_with_flag_enabled) { create(:project, :repository) } - - before do - stub_feature_flags(variable_inside_variable: [project_with_flag_enabled]) - end + let_it_be(:project) { create(:project, :repository) } shared_examples 'returns an array with the expected variables' do it 'returns an array' do @@ -276,21 +271,11 @@ RSpec.describe Ci::BuildRunnerPresenter do end end - context 'when FF :variable_inside_variable is disabled' do - let(:sha) { project_with_flag_disabled.repository.commit.sha } - let(:pipeline) { create(:ci_pipeline, sha: sha, project: project_with_flag_disabled) } - let(:build) { create(:ci_build, pipeline: pipeline) } - - it_behaves_like 'returns an array with the expected variables' - end + let(:sha) { project.repository.commit.sha } + let(:pipeline) { create(:ci_pipeline, sha: sha, project: project) } + let(:build) { create(:ci_build, pipeline: pipeline) } - context 'when FF :variable_inside_variable is enabled' do - let(:sha) { project_with_flag_enabled.repository.commit.sha } - let(:pipeline) { create(:ci_pipeline, sha: sha, project: project_with_flag_enabled) } - let(:build) { create(:ci_build, pipeline: pipeline) } - - it_behaves_like 'returns an array with the expected variables' - end + it_behaves_like 'returns an array with the expected variables' end describe '#runner_variables subset' do @@ -305,32 +290,12 @@ RSpec.describe Ci::BuildRunnerPresenter do create(:ci_pipeline_variable, key: 'C', value: 'value', pipeline: build.pipeline) end - context 'when FF :variable_inside_variable is disabled' do - before do - stub_feature_flags(variable_inside_variable: false) - end - - it 'returns non-expanded variables' do - is_expected.to eq [ - { key: 'A', value: 'refA-$B', public: false, masked: false }, - { key: 'B', value: 'refB-$C-$D', public: false, masked: false }, - { key: 'C', value: 'value', public: false, masked: false } - ] - end - end - - context 'when FF :variable_inside_variable is enabled' do - before do - stub_feature_flags(variable_inside_variable: [build.project]) - end - - it 'returns expanded and sorted variables' do - is_expected.to eq [ - { key: 'C', value: 'value', public: false, masked: false }, - { key: 'B', value: 'refB-value-$D', public: false, masked: false }, - { key: 'A', value: 'refA-refB-value-$D', public: false, masked: false } - ] - end + it 'returns expanded and sorted variables' do + is_expected.to eq [ + { key: 'C', value: 'value', public: false, masked: false }, + { key: 'B', value: 'refB-value-$D', public: false, masked: false }, + { key: 'A', value: 'refA-refB-value-$D', public: false, masked: false } + ] end end end diff --git a/spec/presenters/packages/npm/package_presenter_spec.rb b/spec/presenters/packages/npm/package_presenter_spec.rb index 65f69d4056b..49046492ab4 100644 --- a/spec/presenters/packages/npm/package_presenter_spec.rb +++ b/spec/presenters/packages/npm/package_presenter_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe ::Packages::Npm::PackagePresenter do + using RSpec::Parameterized::TableSyntax + let_it_be(:project) { create(:project) } let_it_be(:package_name) { "@#{project.root_namespace.path}/test" } let_it_be(:package1) { create(:npm_package, version: '2.0.4', project: project, name: package_name) } @@ -13,42 +15,88 @@ RSpec.describe ::Packages::Npm::PackagePresenter do let(:presenter) { described_class.new(package_name, packages) } describe '#versions' do - subject { presenter.versions } + let_it_be('package_json') do + { + 'name': package_name, + 'version': '2.0.4', + 'deprecated': 'warning!', + 'bin': './cli.js', + 'directories': ['lib'], + 'engines': { 'npm': '^7.5.6' }, + '_hasShrinkwrap': false, + 'dist': { + 'tarball': 'http://localhost/tarball.tgz', + 'shasum': '1234567890' + }, + 'custom_field': 'foo_bar' + } + end - context 'for packages without dependencies' do - it { is_expected.to be_a(Hash) } - it { expect(subject[package1.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') } - it { expect(subject[package2.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') } + let(:presenter) { described_class.new(package_name, packages, include_metadata: include_metadata) } - ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| - it { expect(subject.dig(package1.version, dependency_type)).to be nil } - it { expect(subject.dig(package2.version, dependency_type)).to be nil } - end + subject { presenter.versions } - it 'avoids N+1 database queries' do - check_n_plus_one(:versions) do - create_list(:npm_package, 5, project: project, name: package_name) + where(:has_dependencies, :has_metadatum, :include_metadata) do + true | true | true + false | true | true + true | false | true + false | false | true + + # TODO : to remove along with packages_npm_abbreviated_metadata + # See https://gitlab.com/gitlab-org/gitlab/-/issues/344827 + true | true | false + false | true | false + true | false | false + false | false | false + end + + with_them do + if params[:has_dependencies] + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| + let_it_be("package_dependency_link_for_#{dependency_type}") { create(:packages_dependency_link, package: package1, dependency_type: dependency_type) } end end - end - context 'for packages with dependencies' do - ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| - let_it_be("package_dependency_link_for_#{dependency_type}") { create(:packages_dependency_link, package: package1, dependency_type: dependency_type) } + if params[:has_metadatum] + let_it_be('package_metadatadum') { create(:npm_metadatum, package: package1, package_json: package_json) } end it { is_expected.to be_a(Hash) } it { expect(subject[package1.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') } it { expect(subject[package2.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') } - ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| - it { expect(subject.dig(package1.version, dependency_type.to_s)).to be_any } + it { expect(subject[package1.version]['custom_field']).to be_blank } + + context 'dependencies' do + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| + if params[:has_dependencies] + it { expect(subject.dig(package1.version, dependency_type.to_s)).to be_any } + else + it { expect(subject.dig(package1.version, dependency_type)).to be nil } + end + + it { expect(subject.dig(package2.version, dependency_type)).to be nil } + end + end + + context 'metadatum' do + ::Packages::Npm::PackagePresenter::PACKAGE_JSON_ALLOWED_FIELDS.each do |metadata_field| + if params[:has_metadatum] && params[:include_metadata] + it { expect(subject.dig(package1.version, metadata_field)).not_to be nil } + else + it { expect(subject.dig(package1.version, metadata_field)).to be nil } + end + + it { expect(subject.dig(package2.version, metadata_field)).to be nil } + end end it 'avoids N+1 database queries' do check_n_plus_one(:versions) do create_list(:npm_package, 5, project: project, name: package_name).each do |npm_package| - ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| - create(:packages_dependency_link, package: npm_package, dependency_type: dependency_type) + if has_dependencies + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| + create(:packages_dependency_link, package: npm_package, dependency_type: dependency_type) + end end end end diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index 5f789f59908..27b777dec5f 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -567,44 +567,27 @@ RSpec.describe ProjectPresenter do end describe '#upload_anchor_data' do - context 'with empty_repo_upload enabled' do + context 'when a user can push to the default branch' do before do - stub_experiments(empty_repo_upload: :candidate) - end - - context 'user can push to branch' do - before do - project.add_developer(user) - end - - it 'returns upload_anchor_data' do - expect(presenter.upload_anchor_data).to have_attributes( - is_link: false, - label: a_string_including('Upload file'), - data: { - "can_push_code" => "true", - "original_branch" => "master", - "path" => "/#{project.full_path}/-/create/master", - "project_path" => project.full_path, - "target_branch" => "master" - } - ) - end + project.add_developer(user) end - context 'user cannot push to branch' do - it 'returns nil' do - expect(presenter.upload_anchor_data).to be_nil - end + it 'returns upload_anchor_data' do + expect(presenter.upload_anchor_data).to have_attributes( + is_link: false, + label: a_string_including('Upload file'), + data: { + "can_push_code" => "true", + "original_branch" => "master", + "path" => "/#{project.full_path}/-/create/master", + "project_path" => project.full_path, + "target_branch" => "master" + } + ) end end - context 'with empty_repo_upload disabled' do - before do - stub_experiments(empty_repo_upload: :control) - project.add_developer(user) - end - + context 'when the user cannot push to default branch' do it 'returns nil' do expect(presenter.upload_anchor_data).to be_nil end @@ -666,7 +649,6 @@ RSpec.describe ProjectPresenter do context 'for a developer' do before do project.add_developer(user) - stub_experiments(empty_repo_upload: :candidate) end it 'orders the items correctly' do @@ -680,16 +662,6 @@ RSpec.describe ProjectPresenter do a_string_including('CI/CD') ) end - - context 'when not in the upload experiment' do - before do - stub_experiments(empty_repo_upload: :control) - end - - it 'does not include upload button' do - expect(empty_repo_statistics_buttons.map(&:label)).not_to start_with(a_string_including('Upload')) - end - end end end @@ -781,20 +753,4 @@ RSpec.describe ProjectPresenter do it { is_expected.to match(/code_quality_walkthrough=true.*template=Code-Quality/) } end - - describe 'empty_repo_upload_experiment?' do - subject { presenter.empty_repo_upload_experiment? } - - it 'returns false when upload_anchor_data is nil' do - allow(presenter).to receive(:upload_anchor_data).and_return(nil) - - expect(subject).to be false - end - - it 'returns true when upload_anchor_data exists' do - allow(presenter).to receive(:upload_anchor_data).and_return(true) - - expect(subject).to be true - end - end end diff --git a/spec/presenters/release_presenter_spec.rb b/spec/presenters/release_presenter_spec.rb index b2e7b684644..925a69ca92d 100644 --- a/spec/presenters/release_presenter_spec.rb +++ b/spec/presenters/release_presenter_spec.rb @@ -63,12 +63,6 @@ RSpec.describe ReleasePresenter do it 'returns its own url' do is_expected.to eq(project_release_url(project, release)) end - - context 'when user is guest' do - let(:user) { guest } - - it { is_expected.to be_nil } - end end describe '#opened_merge_requests_url' do @@ -147,13 +141,5 @@ RSpec.describe ReleasePresenter do it 'returns the release name' do is_expected.to eq release.name end - - context "when a user is not allowed to access any repository information" do - let(:presenter) { described_class.new(release, current_user: guest) } - - it 'returns a replacement name to avoid potentially leaking tag information' do - is_expected.to eq "Release-#{release.id}" - end - end end end diff --git a/spec/requests/admin/applications_controller_spec.rb b/spec/requests/admin/applications_controller_spec.rb new file mode 100644 index 00000000000..03553757080 --- /dev/null +++ b/spec/requests/admin/applications_controller_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::ApplicationsController, :enable_admin_mode do + let_it_be(:admin) { create(:admin) } + let_it_be(:application) { create(:oauth_application, owner_id: nil, owner_type: nil) } + let_it_be(:show_path) { admin_application_path(application) } + let_it_be(:create_path) { admin_applications_path } + + before do + sign_in(admin) + end + + include_examples 'applications controller - GET #show' + + include_examples 'applications controller - POST #create' +end diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb index 95eb503c6bc..6a02f81fcae 100644 --- a/spec/requests/api/api_spec.rb +++ b/spec/requests/api/api_spec.rb @@ -116,7 +116,7 @@ RSpec.describe API::API do 'meta.root_namespace' => project.namespace.full_path, 'meta.user' => user.username, 'meta.client_id' => a_string_matching(%r{\Auser/.+}), - 'meta.feature_category' => 'issue_tracking', + 'meta.feature_category' => 'team_planning', 'route' => '/api/:version/projects/:id/issues') end @@ -200,6 +200,28 @@ RSpec.describe API::API do expect(response).to have_gitlab_http_status(:not_found) end end + + context 'when there is an unhandled exception for an anonymous request' do + it 'logs all application context fields and the route' do + expect(described_class::LOG_FORMATTER).to receive(:call) do |_severity, _datetime, _, data| + expect(data.stringify_keys) + .to include('correlation_id' => an_instance_of(String), + 'meta.caller_id' => 'GET /api/:version/broadcast_messages', + 'meta.remote_ip' => an_instance_of(String), + 'meta.client_id' => a_string_matching(%r{\Aip/.+}), + 'meta.feature_category' => 'navigation', + 'route' => '/api/:version/broadcast_messages') + + expect(data.stringify_keys).not_to include('meta.project', 'meta.root_namespace', 'meta.user') + end + + expect(BroadcastMessage).to receive(:all).and_raise('An error!') + + get(api('/broadcast_messages')) + + expect(response).to have_gitlab_http_status(:internal_server_error) + end + end end describe 'Marginalia comments' do diff --git a/spec/requests/api/ci/jobs_spec.rb b/spec/requests/api/ci/jobs_spec.rb index b6ab9310471..410020b68cd 100644 --- a/spec/requests/api/ci/jobs_spec.rb +++ b/spec/requests/api/ci/jobs_spec.rb @@ -176,6 +176,111 @@ RSpec.describe API::Ci::Jobs do end end + describe 'GET /job/allowed_agents' do + let_it_be(:group) { create(:group) } + let_it_be(:group_agent) { create(:cluster_agent, project: create(:project, group: group)) } + let_it_be(:group_authorization) { create(:agent_group_authorization, agent: group_agent, group: group) } + let_it_be(:project_agent) { create(:cluster_agent, project: project) } + + before(:all) do + project.update!(group: group_authorization.group) + end + + let(:implicit_authorization) { Clusters::Agents::ImplicitAuthorization.new(agent: project_agent) } + + let(:headers) { { API::Ci::Helpers::Runner::JOB_TOKEN_HEADER => job.token } } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user, status: job_status) } + let(:job_status) { 'running' } + let(:params) { {} } + + subject do + get api('/job/allowed_agents'), headers: headers, params: params + end + + before do + subject + end + + context 'when token is valid and user is authorized' do + shared_examples_for 'valid allowed_agents request' do + it 'returns agent info', :aggregate_failures do + expect(response).to have_gitlab_http_status(:ok) + + expect(json_response.dig('job', 'id')).to eq(job.id) + expect(json_response.dig('pipeline', 'id')).to eq(job.pipeline_id) + expect(json_response.dig('project', 'id')).to eq(job.project_id) + expect(json_response.dig('project', 'groups')).to match_array([{ 'id' => group_authorization.group.id }]) + expect(json_response.dig('user', 'id')).to eq(api_user.id) + expect(json_response.dig('user', 'username')).to eq(api_user.username) + expect(json_response.dig('user', 'roles_in_project')).to match_array %w(guest reporter developer) + expect(json_response).not_to include('environment') + expect(json_response['allowed_agents']).to match_array([ + { + 'id' => implicit_authorization.agent_id, + 'config_project' => hash_including('id' => implicit_authorization.agent.project_id), + 'configuration' => implicit_authorization.config + }, + { + 'id' => group_authorization.agent_id, + 'config_project' => hash_including('id' => group_authorization.agent.project_id), + 'configuration' => group_authorization.config + } + ]) + end + end + + it_behaves_like 'valid allowed_agents request' + + context 'when deployment' do + let(:job) { create(:ci_build, :artifacts, :with_deployment, environment: 'production', pipeline: pipeline, user: api_user, status: job_status) } + + it 'includes environment slug' do + expect(json_response.dig('environment', 'slug')).to eq('production') + end + end + + context 'when passing the token as params' do + let(:headers) { {} } + let(:params) { { job_token: job.token } } + + it_behaves_like 'valid allowed_agents request' + end + end + + context 'when user is anonymous' do + let(:api_user) { nil } + + it 'returns unauthorized' do + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when token is invalid because job has finished' do + let(:job_status) { 'success' } + + it 'returns unauthorized' do + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when token is invalid' do + let(:headers) { { API::Ci::Helpers::Runner::JOB_TOKEN_HEADER => 'bad_token' } } + + it 'returns unauthorized' do + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when token is valid but not CI_JOB_TOKEN' do + let(:token) { create(:personal_access_token, user: user) } + let(:headers) { { 'Private-Token' => token.token } } + + it 'returns not found' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + describe 'GET /projects/:id/jobs' do let(:query) { {} } @@ -203,6 +308,7 @@ RSpec.describe API::Ci::Jobs do it 'returns no artifacts nor trace data' do json_job = json_response.first + expect(response).to have_gitlab_http_status(:ok) expect(json_job['artifacts_file']).to be_nil expect(json_job['artifacts']).to be_an Array expect(json_job['artifacts']).to be_empty @@ -321,6 +427,22 @@ RSpec.describe API::Ci::Jobs do expect(response).to have_gitlab_http_status(:unauthorized) end end + + context 'when trace artifact record exists with no stored file', :skip_before_request do + before do + create(:ci_job_artifact, :unarchived_trace_artifact, job: job, project: job.project) + end + + it 'returns no artifacts nor trace data' do + get api("/projects/#{project.id}/jobs/#{job.id}", api_user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['artifacts']).to be_an Array + expect(json_response['artifacts'].size).to eq(1) + expect(json_response['artifacts'][0]['file_type']).to eq('trace') + expect(json_response['artifacts'][0]['filename']).to eq('job.log') + end + end end describe 'DELETE /projects/:id/jobs/:job_id/artifacts' do @@ -456,6 +578,7 @@ RSpec.describe API::Ci::Jobs do expect(response.headers.to_h) .to include('Content-Type' => 'application/json', 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + expect(response.parsed_body).to be_empty end context 'when artifacts are locked' do @@ -826,6 +949,7 @@ RSpec.describe API::Ci::Jobs do expect(response.headers.to_h) .to include('Content-Type' => 'application/json', 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + expect(response.parsed_body).to be_empty end end @@ -919,7 +1043,16 @@ RSpec.describe API::Ci::Jobs do end end - context 'when trace is file' do + context 'when live trace and uploadless trace artifact' do + let(:job) { create(:ci_build, :trace_live, :unarchived_trace_artifact, pipeline: pipeline) } + + it 'returns specific job trace' do + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to eq(job.trace.raw) + end + end + + context 'when trace is live' do let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) } it 'returns specific job trace' do @@ -927,6 +1060,28 @@ RSpec.describe API::Ci::Jobs do expect(response.body).to eq(job.trace.raw) end end + + context 'when no trace' do + let(:job) { create(:ci_build, pipeline: pipeline) } + + it 'returns empty trace' do + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to be_empty + end + end + + context 'when trace artifact record exists with no stored file' do + let(:job) { create(:ci_build, pipeline: pipeline) } + + before do + create(:ci_job_artifact, :unarchived_trace_artifact, job: job, project: job.project) + end + + it 'returns empty trace' do + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to be_empty + end + end end context 'unauthorized user' do @@ -1038,9 +1193,7 @@ RSpec.describe API::Ci::Jobs do post api("/projects/#{project.id}/jobs/#{job.id}/erase", user) end - context 'job is erasable' do - let(:job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, :success, project: project, pipeline: pipeline) } - + shared_examples_for 'erases job' do it 'erases job content' do expect(response).to have_gitlab_http_status(:created) expect(job.job_artifacts.count).to eq(0) @@ -1049,6 +1202,12 @@ RSpec.describe API::Ci::Jobs do expect(job.artifacts_metadata.present?).to be_falsy expect(job.has_job_artifacts?).to be_falsy end + end + + context 'job is erasable' do + let(:job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, :success, project: project, pipeline: pipeline) } + + it_behaves_like 'erases job' it 'updates job' do job.reload @@ -1058,6 +1217,12 @@ RSpec.describe API::Ci::Jobs do end end + context 'when job has an unarchived trace artifact' do + let(:job) { create(:ci_build, :success, :trace_live, :unarchived_trace_artifact, project: project, pipeline: pipeline) } + + it_behaves_like 'erases job' + end + context 'job is not erasable' do let(:job) { create(:ci_build, :trace_live, project: project, pipeline: pipeline) } diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb index c3fbef9be48..fdf1a278d4c 100644 --- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb +++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb @@ -218,9 +218,11 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(json_response['git_info']).to eq(expected_git_info) expect(json_response['image']).to eq({ 'name' => 'ruby:2.7', 'entrypoint' => '/bin/sh', 'ports' => [] }) expect(json_response['services']).to eq([{ 'name' => 'postgres', 'entrypoint' => nil, - 'alias' => nil, 'command' => nil, 'ports' => [] }, + 'alias' => nil, 'command' => nil, 'ports' => [], 'variables' => nil }, { 'name' => 'docker:stable-dind', 'entrypoint' => '/bin/sh', - 'alias' => 'docker', 'command' => 'sleep 30', 'ports' => [] }]) + 'alias' => 'docker', 'command' => 'sleep 30', 'ports' => [], 'variables' => [] }, + { 'name' => 'mysql:latest', 'entrypoint' => nil, + 'alias' => nil, 'command' => nil, 'ports' => [], 'variables' => [{ 'key' => 'MYSQL_ROOT_PASSWORD', 'value' => 'root123.' }] }]) expect(json_response['steps']).to eq(expected_steps) expect(json_response['artifacts']).to eq(expected_artifacts) expect(json_response['cache']).to eq(expected_cache) diff --git a/spec/requests/api/debian_group_packages_spec.rb b/spec/requests/api/debian_group_packages_spec.rb index 3e11b480860..d881d4350fb 100644 --- a/spec/requests/api/debian_group_packages_spec.rb +++ b/spec/requests/api/debian_group_packages_spec.rb @@ -9,35 +9,36 @@ RSpec.describe API::DebianGroupPackages do context 'with invalid parameter' do let(:url) { "/groups/1/-/packages/debian/dists/with+space/InRelease" } - it_behaves_like 'Debian repository GET request', :bad_request, /^distribution is invalid$/ + it_behaves_like 'Debian packages GET request', :bad_request, /^distribution is invalid$/ end describe 'GET groups/:id/-/packages/debian/dists/*distribution/Release.gpg' do let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/Release.gpg" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^-----BEGIN PGP SIGNATURE-----/ + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNATURE-----/ end describe 'GET groups/:id/-/packages/debian/dists/*distribution/Release' do let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/Release" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^Codename: fixture-distribution\n$/ + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Codename: fixture-distribution\n$/ end describe 'GET groups/:id/-/packages/debian/dists/*distribution/InRelease' do let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/InRelease" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^-----BEGIN PGP SIGNED MESSAGE-----/ + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNED MESSAGE-----/ end describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/Packages" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /Description: This is an incomplete Packages file/ + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Packages file/ end describe 'GET groups/:id/-/packages/debian/pool/:codename/:project_id/:letter/:package_name/:package_version/:file_name' do let(:url) { "/groups/#{container.id}/-/packages/debian/pool/#{package.debian_distribution.codename}/#{project.id}/#{letter}/#{package.name}/#{package.version}/#{file_name}" } + let(:file_name) { params[:file_name] } using RSpec::Parameterized::TableSyntax @@ -51,9 +52,7 @@ RSpec.describe API::DebianGroupPackages do end with_them do - include_context 'with file_name', params[:file_name] - - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, params[:success_body] + it_behaves_like 'Debian packages read endpoint', 'GET', :success, params[:success_body] end end end diff --git a/spec/requests/api/debian_project_packages_spec.rb b/spec/requests/api/debian_project_packages_spec.rb index d0b0debaf13..bd68bf912e1 100644 --- a/spec/requests/api/debian_project_packages_spec.rb +++ b/spec/requests/api/debian_project_packages_spec.rb @@ -9,35 +9,36 @@ RSpec.describe API::DebianProjectPackages do context 'with invalid parameter' do let(:url) { "/projects/1/packages/debian/dists/with+space/InRelease" } - it_behaves_like 'Debian repository GET request', :bad_request, /^distribution is invalid$/ + it_behaves_like 'Debian packages GET request', :bad_request, /^distribution is invalid$/ end describe 'GET projects/:id/packages/debian/dists/*distribution/Release.gpg' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/Release.gpg" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^-----BEGIN PGP SIGNATURE-----/ + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNATURE-----/ end describe 'GET projects/:id/packages/debian/dists/*distribution/Release' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/Release" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^Codename: fixture-distribution\n$/ + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^Codename: fixture-distribution\n$/ end describe 'GET projects/:id/packages/debian/dists/*distribution/InRelease' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/InRelease" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^-----BEGIN PGP SIGNED MESSAGE-----/ + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /^-----BEGIN PGP SIGNED MESSAGE-----/ end describe 'GET projects/:id/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/binary-#{architecture.name}/Packages" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /Description: This is an incomplete Packages file/ + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Packages file/ end describe 'GET projects/:id/packages/debian/pool/:codename/:letter/:package_name/:package_version/:file_name' do let(:url) { "/projects/#{container.id}/packages/debian/pool/#{package.debian_distribution.codename}/#{letter}/#{package.name}/#{package.version}/#{file_name}" } + let(:file_name) { params[:file_name] } using RSpec::Parameterized::TableSyntax @@ -51,9 +52,7 @@ RSpec.describe API::DebianProjectPackages do end with_them do - include_context 'with file_name', params[:file_name] - - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, params[:success_body] + it_behaves_like 'Debian packages read endpoint', 'GET', :success, params[:success_body] end end @@ -65,13 +64,13 @@ RSpec.describe API::DebianProjectPackages do context 'with a deb' do let(:file_name) { 'libsample0_1.2.3~alpha2_amd64.deb' } - it_behaves_like 'Debian repository write endpoint', 'upload request', :created + it_behaves_like 'Debian packages write endpoint', 'upload', :created, nil end context 'with a changes file' do let(:file_name) { 'sample_1.2.3~alpha2_amd64.changes' } - it_behaves_like 'Debian repository write endpoint', 'upload request', :created + it_behaves_like 'Debian packages write endpoint', 'upload', :created, nil end end @@ -80,7 +79,7 @@ RSpec.describe API::DebianProjectPackages do let(:method) { :put } let(:url) { "/projects/#{container.id}/packages/debian/#{file_name}/authorize" } - it_behaves_like 'Debian repository write endpoint', 'upload authorize request', :created + it_behaves_like 'Debian packages write endpoint', 'upload authorize', :created, nil end end end diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb index a01c66a311c..1daa7c38e04 100644 --- a/spec/requests/api/deploy_keys_spec.rb +++ b/spec/requests/api/deploy_keys_spec.rb @@ -8,8 +8,9 @@ RSpec.describe API::DeployKeys do let_it_be(:admin) { create(:admin) } let_it_be(:project) { create(:project, creator_id: user.id) } let_it_be(:project2) { create(:project, creator_id: user.id) } - - let(:deploy_key) { create(:deploy_key, public: true) } + let_it_be(:project3) { create(:project, creator_id: user.id) } + let_it_be(:deploy_key) { create(:deploy_key, public: true) } + let_it_be(:deploy_key_private) { create(:deploy_key, public: false) } let!(:deploy_keys_project) do create(:deploy_keys_project, project: project, deploy_key: deploy_key) @@ -33,13 +34,56 @@ RSpec.describe API::DeployKeys do end context 'when authenticated as admin' do + let_it_be(:pat) { create(:personal_access_token, user: admin) } + + def make_api_request(params = {}) + get api('/deploy_keys', personal_access_token: pat), params: params + end + it 'returns all deploy keys' do - get api('/deploy_keys', admin) + make_api_request expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers + expect(response).to match_response_schema('public_api/v4/deploy_keys') expect(json_response).to be_an Array - expect(json_response.first['id']).to eq(deploy_keys_project.deploy_key.id) + + expect(json_response[0]['id']).to eq(deploy_key.id) + expect(json_response[1]['id']).to eq(deploy_key_private.id) + end + + it 'avoids N+1 database queries', :use_sql_query_cache, :request_store do + create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) { make_api_request } + + deploy_key2 = create(:deploy_key, public: true) + create(:deploy_keys_project, :write_access, project: project3, deploy_key: deploy_key2) + + expect { make_api_request }.not_to exceed_all_query_limit(control) + end + + context 'when `public` parameter is `true`' do + it 'only returns public deploy keys' do + make_api_request({ public: true }) + + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(deploy_key.id) + end + end + + context 'projects_with_write_access' do + let!(:deploy_keys_project2) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key) } + let!(:deploy_keys_project3) { create(:deploy_keys_project, :write_access, project: project3, deploy_key: deploy_key) } + + it 'returns projects with write access' do + make_api_request + + response_projects_with_write_access = json_response.first['projects_with_write_access'] + + expect(response_projects_with_write_access[0]['id']).to eq(project2.id) + expect(response_projects_with_write_access[1]['id']).to eq(project3.id) + end end end end @@ -58,6 +102,7 @@ RSpec.describe API::DeployKeys do expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.first['title']).to eq(deploy_key.title) + expect(json_response.first).not_to have_key(:projects_with_write_access) end it 'returns multiple deploy keys without N + 1' do @@ -77,6 +122,7 @@ RSpec.describe API::DeployKeys do expect(response).to have_gitlab_http_status(:ok) expect(json_response['title']).to eq(deploy_key.title) + expect(json_response).not_to have_key(:projects_with_write_access) end it 'returns 404 Not Found with invalid ID' do diff --git a/spec/requests/api/error_tracking/collector_spec.rb b/spec/requests/api/error_tracking/collector_spec.rb index 7acadeb1287..21e2849fef0 100644 --- a/spec/requests/api/error_tracking/collector_spec.rb +++ b/spec/requests/api/error_tracking/collector_spec.rb @@ -24,10 +24,10 @@ RSpec.describe API::ErrorTracking::Collector do end RSpec.shared_examples 'successful request' do - it 'writes to the database and returns no content' do + it 'writes to the database and returns OK' do expect { subject }.to change { ErrorTracking::ErrorEvent.count }.by(1) - expect(response).to have_gitlab_http_status(:no_content) + expect(response).to have_gitlab_http_status(:ok) end end @@ -89,13 +89,27 @@ RSpec.describe API::ErrorTracking::Collector do context 'transaction request type' do let(:params) { fixture_file('error_tracking/transaction.txt') } - it 'does nothing and returns no content' do + it 'does nothing and returns ok' do expect { subject }.not_to change { ErrorTracking::ErrorEvent.count } - expect(response).to have_gitlab_http_status(:no_content) + expect(response).to have_gitlab_http_status(:ok) end end + context 'gzip body' do + let(:headers) do + { + 'X-Sentry-Auth' => "Sentry sentry_key=#{client_key.public_key}", + 'HTTP_CONTENT_ENCODING' => 'gzip', + 'CONTENT_TYPE' => 'application/x-sentry-envelope' + } + end + + let(:params) { ActiveSupport::Gzip.compress(raw_event) } + + it_behaves_like 'successful request' + end + it_behaves_like 'successful request' end @@ -122,6 +136,35 @@ RSpec.describe API::ErrorTracking::Collector do it_behaves_like 'bad request' end + context 'body with string instead of json' do + let(:params) { '"********"' } + + it_behaves_like 'bad request' + end + + context 'collector fails with validation error' do + before do + allow(::ErrorTracking::CollectErrorService) + .to receive(:new).and_raise(ActiveRecord::RecordInvalid) + end + + it_behaves_like 'bad request' + end + + context 'gzip body' do + let(:headers) do + { + 'X-Sentry-Auth' => "Sentry sentry_key=#{client_key.public_key}", + 'HTTP_CONTENT_ENCODING' => 'gzip', + 'CONTENT_TYPE' => 'application/json' + } + end + + let(:params) { ActiveSupport::Gzip.compress(raw_event) } + + it_behaves_like 'successful request' + end + context 'sentry_key as param and empty headers' do let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" } let(:headers) { {} } diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb index 0e163ec2154..35dba93b766 100644 --- a/spec/requests/api/features_spec.rb +++ b/spec/requests/api/features_spec.rb @@ -256,6 +256,21 @@ RSpec.describe API::Features, stub_feature_flags: false do ) end + it 'creates a feature with the given percentage of time if passed a float' do + post api("/features/#{feature_name}", admin), params: { value: '0.01' } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response).to match( + 'name' => feature_name, + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'percentage_of_time', 'value' => 0.01 } + ], + 'definition' => known_feature_flag_definition_hash + ) + end + it 'creates a feature with the given percentage of actors if passed an integer' do post api("/features/#{feature_name}", admin), params: { value: '50', key: 'percentage_of_actors' } @@ -270,6 +285,21 @@ RSpec.describe API::Features, stub_feature_flags: false do 'definition' => known_feature_flag_definition_hash ) end + + it 'creates a feature with the given percentage of actors if passed a float' do + post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors' } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response).to match( + 'name' => feature_name, + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'percentage_of_actors', 'value' => 0.01 } + ], + 'definition' => known_feature_flag_definition_hash + ) + end end context 'when the feature exists' do diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 0b898496dd6..6aa12b6ff48 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -47,6 +47,15 @@ RSpec.describe API::Files do "/projects/#{project.id}/repository/files/#{file_path}" end + def expect_to_send_git_blob(url, params) + expect(Gitlab::Workhorse).to receive(:send_git_blob) + + get url, params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response.parsed_body).to be_empty + end + context 'http headers' do it 'converts value into string' do helper.set_http_headers(test: 1) @@ -257,11 +266,7 @@ RSpec.describe API::Files do it 'returns raw file info' do url = route(file_path) + "/raw" - expect(Gitlab::Workhorse).to receive(:send_git_blob) - - get api(url, api_user, **options), params: params - - expect(response).to have_gitlab_http_status(:ok) + expect_to_send_git_blob(api(url, api_user, **options), params) expect(headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" end @@ -523,11 +528,8 @@ RSpec.describe API::Files do it 'returns raw file info' do url = route(file_path) + "/raw" - expect(Gitlab::Workhorse).to receive(:send_git_blob) - get api(url, current_user), params: params - - expect(response).to have_gitlab_http_status(:ok) + expect_to_send_git_blob(api(url, current_user), params) end context 'when ref is not provided' do @@ -537,39 +539,29 @@ RSpec.describe API::Files do it 'returns response :ok', :aggregate_failures do url = route(file_path) + "/raw" - expect(Gitlab::Workhorse).to receive(:send_git_blob) - get api(url, current_user), params: {} - - expect(response).to have_gitlab_http_status(:ok) + expect_to_send_git_blob(api(url, current_user), {}) end end it 'returns raw file info for files with dots' do url = route('.gitignore') + "/raw" - expect(Gitlab::Workhorse).to receive(:send_git_blob) - get api(url, current_user), params: params - - expect(response).to have_gitlab_http_status(:ok) + expect_to_send_git_blob(api(url, current_user), params) end it 'returns file by commit sha' do # This file is deleted on HEAD file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee" params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9" - expect(Gitlab::Workhorse).to receive(:send_git_blob) - get api(route(file_path) + "/raw", current_user), params: params - - expect(response).to have_gitlab_http_status(:ok) + expect_to_send_git_blob(api(route(file_path) + "/raw", current_user), params) end it 'sets no-cache headers' do url = route('.gitignore') + "/raw" - expect(Gitlab::Workhorse).to receive(:send_git_blob) - get api(url, current_user), params: params + expect_to_send_git_blob(api(url, current_user), params) expect(response.headers["Cache-Control"]).to eq("max-age=0, private, must-revalidate, no-store, no-cache") expect(response.headers["Pragma"]).to eq("no-cache") @@ -633,11 +625,9 @@ RSpec.describe API::Files do # This file is deleted on HEAD file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee" params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9" - expect(Gitlab::Workhorse).to receive(:send_git_blob) + url = api(route(file_path) + "/raw", personal_access_token: token) - get api(route(file_path) + "/raw", personal_access_token: token), params: params - - expect(response).to have_gitlab_http_status(:ok) + expect_to_send_git_blob(url, params) end end end diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb index 7e439a22e4b..2d85d7b9583 100644 --- a/spec/requests/api/generic_packages_spec.rb +++ b/spec/requests/api/generic_packages_spec.rb @@ -297,6 +297,37 @@ RSpec.describe API::GenericPackages do end end + context 'with select' do + context 'with a valid value' do + context 'package_file' do + let(:params) { super().merge(select: 'package_file') } + + it 'returns a package file' do + headers = workhorse_headers.merge(auth_header) + + upload_file(params, headers) + + aggregate_failures do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to have_key('id') + end + end + end + end + + context 'with an invalid value' do + let(:params) { super().merge(select: 'invalid_value') } + + it 'returns a package file' do + headers = workhorse_headers.merge(auth_header) + + upload_file(params, headers) + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + context 'with a status' do context 'valid status' do let(:params) { super().merge(status: 'hidden') } diff --git a/spec/requests/api/graphql/ci/pipelines_spec.rb b/spec/requests/api/graphql/ci/pipelines_spec.rb index 6587061094d..1f47f678898 100644 --- a/spec/requests/api/graphql/ci/pipelines_spec.rb +++ b/spec/requests/api/graphql/ci/pipelines_spec.rb @@ -186,6 +186,69 @@ RSpec.describe 'Query.project(fullPath).pipelines' do end end + describe '.job_artifacts' do + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:pipeline_job_1) { create(:ci_build, pipeline: pipeline, name: 'Job 1') } + let_it_be(:pipeline_job_artifact_1) { create(:ci_job_artifact, job: pipeline_job_1) } + let_it_be(:pipeline_job_2) { create(:ci_build, pipeline: pipeline, name: 'Job 2') } + let_it_be(:pipeline_job_artifact_2) { create(:ci_job_artifact, job: pipeline_job_2) } + + let(:path) { %i[project pipelines nodes jobArtifacts] } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + pipelines { + nodes { + jobArtifacts { + name + downloadPath + fileType + } + } + } + } + } + ) + end + + before do + post_graphql(query, current_user: user) + end + + it_behaves_like 'a working graphql query' + + it 'returns the job_artifacts of a pipeline' do + job_artifacts_graphql_data = graphql_data_at(*path).flatten + + expect( + job_artifacts_graphql_data.map { |pip| pip['name'] } + ).to contain_exactly(pipeline_job_artifact_1.filename, pipeline_job_artifact_2.filename) + end + + it 'avoids N+1 queries' do + first_user = create(:user) + second_user = create(:user) + + control_count = ActiveRecord::QueryRecorder.new do + post_graphql(query, current_user: first_user) + end + + pipeline_2 = create(:ci_pipeline, project: project) + pipeline_2_job_1 = create(:ci_build, pipeline: pipeline_2, name: 'Pipeline 2 Job 1') + create(:ci_job_artifact, job: pipeline_2_job_1) + pipeline_2_job_2 = create(:ci_build, pipeline: pipeline_2, name: 'Pipeline 2 Job 2') + create(:ci_job_artifact, job: pipeline_2_job_2) + + expect do + post_graphql(query, current_user: second_user) + end.not_to exceed_query_limit(control_count) + + expect(response).to have_gitlab_http_status(:ok) + end + end + describe '.jobs(securityReportTypes)' do let_it_be(:query) do %( diff --git a/spec/requests/api/graphql/gitlab_schema_spec.rb b/spec/requests/api/graphql/gitlab_schema_spec.rb index b41d851439b..8bbeae97f57 100644 --- a/spec/requests/api/graphql/gitlab_schema_spec.rb +++ b/spec/requests/api/graphql/gitlab_schema_spec.rb @@ -190,19 +190,18 @@ RSpec.describe 'GitlabSchema configurations' do let(:query) { File.read(Rails.root.join('spec/fixtures/api/graphql/introspection.graphql')) } it 'logs the query complexity and depth' do - analyzer_memo = { - query_string: query, - variables: {}.to_s, - complexity: 181, - depth: 13, - duration_s: 7, - operation_name: 'IntrospectionQuery', - used_fields: an_instance_of(Array), - used_deprecated_fields: an_instance_of(Array) - } - expect_any_instance_of(Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer).to receive(:duration).and_return(7) - expect(Gitlab::GraphqlLogger).to receive(:info).with(analyzer_memo) + + expect(Gitlab::GraphqlLogger).to receive(:info).with( + hash_including( + trace_type: 'execute_query', + "query_analysis.duration_s" => 7, + "query_analysis.complexity" => 181, + "query_analysis.depth" => 13, + "query_analysis.used_deprecated_fields" => an_instance_of(Array), + "query_analysis.used_fields" => an_instance_of(Array) + ) + ) post_graphql(query, current_user: nil) end diff --git a/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb index 30e704adb92..3527c8183f6 100644 --- a/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb +++ b/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb @@ -116,4 +116,26 @@ RSpec.describe 'getting dependency proxy manifests in a group' do expect(dependency_proxy_image_count_response).to eq(manifests.size) end + + describe 'sorting and pagination' do + let(:data_path) { ['group', :dependencyProxyManifests] } + let(:current_user) { owner } + + context 'with default sorting' do + let_it_be(:descending_manifests) { manifests.reverse.map { |manifest| global_id_of(manifest)} } + + it_behaves_like 'sorted paginated query' do + let(:sort_param) { '' } + let(:first_param) { 2 } + let(:all_records) { descending_manifests } + end + end + + def pagination_query(params) + # remove sort since the type does not accept sorting, but be future proof + graphql_query_for('group', { 'fullPath' => group.full_path }, + query_nodes(:dependencyProxyManifests, :id, include_pagination_info: true, args: params.merge(sort: nil)) + ) + end + end end diff --git a/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb b/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb index 0fd8fdc3f59..322706be119 100644 --- a/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'RunnersRegistrationTokenReset' do subject expect(graphql_errors).not_to be_empty - expect(graphql_errors).to include(a_hash_including('message' => "The resource that you are attempting to access does not exist or you don't have permission to perform this action")) + expect(graphql_errors).to include(a_hash_including('message' => Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)) expect(mutation_response).to be_nil end end diff --git a/spec/requests/api/graphql/mutations/design_management/delete_spec.rb b/spec/requests/api/graphql/mutations/design_management/delete_spec.rb index e329416faee..1dffb86b344 100644 --- a/spec/requests/api/graphql/mutations/design_management/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/design_management/delete_spec.rb @@ -53,7 +53,7 @@ RSpec.describe "deleting designs" do context 'the designs list contains filenames we cannot find' do it_behaves_like 'a failed request' do - let(:designs) { %w/foo bar baz/.map { |fn| OpenStruct.new(filename: fn) } } + let(:designs) { %w/foo bar baz/.map { |fn| instance_double('file', filename: fn) } } let(:the_error) { a_string_matching %r/filenames were not found/ } end end diff --git a/spec/requests/api/graphql/mutations/issues/create_spec.rb b/spec/requests/api/graphql/mutations/issues/create_spec.rb index 886f3140086..6baed352b37 100644 --- a/spec/requests/api/graphql/mutations/issues/create_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/create_spec.rb @@ -48,5 +48,9 @@ RSpec.describe 'Create an issue' do expect(mutation_response['issue']).to include('discussionLocked' => true) expect(Issue.last.work_item_type.base_type).to eq('issue') end + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::Issues::Create } + end end end diff --git a/spec/requests/api/graphql/mutations/issues/move_spec.rb b/spec/requests/api/graphql/mutations/issues/move_spec.rb index 5bbaff61edd..20ed16879f6 100644 --- a/spec/requests/api/graphql/mutations/issues/move_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/move_spec.rb @@ -33,7 +33,7 @@ RSpec.describe 'Moving an issue' do context 'when the user is not allowed to read source project' do it 'returns an error' do - error = "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR post_graphql_mutation(mutation, current_user: user) expect(response).to have_gitlab_http_status(:success) diff --git a/spec/requests/api/graphql/mutations/issues/set_confidential_spec.rb b/spec/requests/api/graphql/mutations/issues/set_confidential_spec.rb index 3f804a46992..12ab504da14 100644 --- a/spec/requests/api/graphql/mutations/issues/set_confidential_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/set_confidential_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Setting an issue as confidential' do end it 'returns an error if the user is not allowed to update the issue' do - error = "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR post_graphql_mutation(mutation, current_user: create(:user)) expect(graphql_errors).to include(a_hash_including('message' => error)) diff --git a/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb new file mode 100644 index 00000000000..3da702c55d7 --- /dev/null +++ b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Setting issues crm contacts' do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:contacts) { create_list(:contact, 4, group: group) } + + let(:issue) { create(:issue, project: project) } + let(:operation_mode) { Types::MutationOperationModeEnum.default_mode } + let(:crm_contact_ids) { [global_id_of(contacts[1]), global_id_of(contacts[2])] } + let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" } + + let(:mutation) do + variables = { + project_path: issue.project.full_path, + iid: issue.iid.to_s, + operation_mode: operation_mode, + crm_contact_ids: crm_contact_ids + } + + graphql_mutation(:issue_set_crm_contacts, variables, + <<-QL.strip_heredoc + clientMutationId + errors + issue { + customerRelationsContacts { + nodes { + id + } + } + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:issue_set_crm_contacts) + end + + before do + create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]) + create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]) + end + + context 'when the user has no permission' do + it 'returns expected error' do + error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).to include(a_hash_including('message' => error)) + end + end + + context 'when the user has permission' do + before do + group.add_reporter(user) + end + + context 'when the feature is disabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it 'raises expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).to include(a_hash_including('message' => 'Feature disabled')) + end + end + + context 'replace' do + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array([global_id_of(contacts[1]), global_id_of(contacts[2])]) + end + end + + context 'append' do + let(:crm_contact_ids) { [global_id_of(contacts[3])] } + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] } + + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array([global_id_of(contacts[0]), global_id_of(contacts[1]), global_id_of(contacts[3])]) + end + end + + context 'remove' do + let(:crm_contact_ids) { [global_id_of(contacts[0])] } + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] } + + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array([global_id_of(contacts[1])]) + end + end + + context 'when the contact does not exist' do + let(:crm_contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] } + + it 'returns expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :errors)) + .to match_array(["Issue customer relations contacts #{non_existing_record_id}: #{does_not_exist_or_no_permission}"]) + end + end + + context 'when the contact belongs to a different group' do + let(:group2) { create(:group) } + let(:contact) { create(:contact, group: group2) } + let(:crm_contact_ids) { [global_id_of(contact)] } + + before do + group2.add_reporter(user) + end + + it 'returns expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :errors)) + .to match_array(["Issue customer relations contacts #{contact.id}: #{does_not_exist_or_no_permission}"]) + end + end + + context 'when attempting to add more than 6' do + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] } + let(:gid) { global_id_of(contacts[0]) } + let(:crm_contact_ids) { [gid, gid, gid, gid, gid, gid, gid] } + + it 'returns expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :errors)) + .to match_array(["You can only add up to 6 contacts at one time"]) + end + end + + context 'when trying to remove non-existent contact' do + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] } + let(:crm_contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] } + + it 'raises expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :errors)).to be_empty + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/issues/set_due_date_spec.rb b/spec/requests/api/graphql/mutations/issues/set_due_date_spec.rb index 72e47a98373..8e223b6fdaf 100644 --- a/spec/requests/api/graphql/mutations/issues/set_due_date_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/set_due_date_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Setting Due Date of an issue' do end it 'returns an error if the user is not allowed to update the issue' do - error = "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR post_graphql_mutation(mutation, current_user: create(:user)) expect(graphql_errors).to include(a_hash_including('message' => error)) diff --git a/spec/requests/api/graphql/mutations/issues/set_severity_spec.rb b/spec/requests/api/graphql/mutations/issues/set_severity_spec.rb index 41997f151a2..cd9d695bd2c 100644 --- a/spec/requests/api/graphql/mutations/issues/set_severity_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/set_severity_spec.rb @@ -35,7 +35,7 @@ RSpec.describe 'Setting severity level of an incident' do context 'when the user is not allowed to update the incident' do it 'returns an error' do - error = "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR post_graphql_mutation(mutation, current_user: user) expect(response).to have_gitlab_http_status(:success) diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_draft_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_draft_spec.rb new file mode 100644 index 00000000000..bea2365eaa6 --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_draft_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Setting Draft status of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:input) { { draft: true } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_draft, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + title + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_draft) + end + + before do + project.add_developer(current_user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'marks the merge request as Draft' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['mergeRequest']['title']).to start_with('Draft:') + end + + it 'does not do anything if the merge request was already marked `Draft`' do + merge_request.update!(title: 'draft: hello world') + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['mergeRequest']['title']).to start_with('draft:') + end + + context 'when passing Draft false as input' do + let(:input) { { draft: false } } + + it 'does not do anything if the merge reqeust was not marked draft' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['mergeRequest']['title']).not_to start_with(/draft\:/) + end + + it 'unmarks the merge request as `Draft`' do + merge_request.update!(title: 'draft: hello world') + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['mergeRequest']['title']).not_to start_with('/draft\:/') + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_wip_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_wip_spec.rb deleted file mode 100644 index 2143abd3031..00000000000 --- a/spec/requests/api/graphql/mutations/merge_requests/set_wip_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Setting Draft status of a merge request' do - include GraphqlHelpers - - let(:current_user) { create(:user) } - let(:merge_request) { create(:merge_request) } - let(:project) { merge_request.project } - let(:input) { { wip: true } } - - let(:mutation) do - variables = { - project_path: project.full_path, - iid: merge_request.iid.to_s - } - graphql_mutation(:merge_request_set_wip, variables.merge(input), - <<-QL.strip_heredoc - clientMutationId - errors - mergeRequest { - id - title - } - QL - ) - end - - def mutation_response - graphql_mutation_response(:merge_request_set_wip) - end - - before do - project.add_developer(current_user) - end - - it 'returns an error if the user is not allowed to update the merge request' do - post_graphql_mutation(mutation, current_user: create(:user)) - - expect(graphql_errors).not_to be_empty - end - - it 'marks the merge request as Draft' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['mergeRequest']['title']).to start_with('Draft:') - end - - it 'does not do anything if the merge request was already marked `Draft`' do - merge_request.update!(title: 'draft: hello world') - - post_graphql_mutation(mutation, current_user: current_user) - - expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['mergeRequest']['title']).to start_with('draft:') - end - - context 'when passing Draft false as input' do - let(:input) { { wip: false } } - - it 'does not do anything if the merge reqeust was not marked draft' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['mergeRequest']['title']).not_to start_with(/draft\:/) - end - - it 'unmarks the merge request as `Draft`' do - merge_request.update!(title: 'draft: hello world') - - post_graphql_mutation(mutation, current_user: current_user) - - expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['mergeRequest']['title']).not_to start_with('/draft\:/') - end - end -end diff --git a/spec/requests/api/graphql/mutations/merge_requests/update_reviewer_state_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/update_reviewer_state_spec.rb new file mode 100644 index 00000000000..cf497cb2579 --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/update_reviewer_state_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Toggle attention requested for reviewer' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request, reviewers: [user]) } + let(:project) { merge_request.project } + let(:user) { create(:user) } + let(:input) { { user_id: global_id_of(user) } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_toggle_attention_requested, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_toggle_attention_requested) + end + + def mutation_errors + mutation_response['errors'] + end + + before do + project.add_developer(current_user) + project.add_developer(user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + describe 'reviewer does not exist' do + let(:input) { { user_id: global_id_of(create(:user)) } } + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_errors).not_to be_empty + end + end + + describe 'reviewer exists' do + it 'does not return an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_errors).to be_empty + end + end +end diff --git a/spec/requests/api/graphql/mutations/releases/create_spec.rb b/spec/requests/api/graphql/mutations/releases/create_spec.rb index a4918cd560c..86995c10f10 100644 --- a/spec/requests/api/graphql/mutations/releases/create_spec.rb +++ b/spec/requests/api/graphql/mutations/releases/create_spec.rb @@ -342,7 +342,7 @@ RSpec.describe 'Creation of a new release' do end context "when the current user doesn't have access to create releases" do - expected_error_message = "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + expected_error_message = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR context 'when the current user is a Reporter' do let(:current_user) { reporter } diff --git a/spec/requests/api/graphql/mutations/releases/delete_spec.rb b/spec/requests/api/graphql/mutations/releases/delete_spec.rb index 40063156609..eb4f0b594ea 100644 --- a/spec/requests/api/graphql/mutations/releases/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/releases/delete_spec.rb @@ -50,7 +50,7 @@ RSpec.describe 'Deleting a release' do expect(mutation_response).to be_nil expect(graphql_errors.count).to eq(1) - expect(graphql_errors.first['message']).to eq("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + expect(graphql_errors.first['message']).to eq(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end end diff --git a/spec/requests/api/graphql/mutations/releases/update_spec.rb b/spec/requests/api/graphql/mutations/releases/update_spec.rb index c9a6c3abd57..0fa3d7de299 100644 --- a/spec/requests/api/graphql/mutations/releases/update_spec.rb +++ b/spec/requests/api/graphql/mutations/releases/update_spec.rb @@ -218,13 +218,13 @@ RSpec.describe 'Updating an existing release' do context 'when the project does not exist' do let(:mutation_arguments) { super().merge(projectPath: 'not/a/real/path') } - it_behaves_like 'top-level error with message', "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + it_behaves_like 'top-level error with message', Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR end end end context "when the current user doesn't have access to update releases" do - expected_error_message = "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + expected_error_message = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR context 'when the current user is a Reporter' do let(:current_user) { reporter } diff --git a/spec/requests/api/graphql/mutations/security/ci_configuration/configure_sast_iac_spec.rb b/spec/requests/api/graphql/mutations/security/ci_configuration/configure_sast_iac_spec.rb new file mode 100644 index 00000000000..929609d4160 --- /dev/null +++ b/spec/requests/api/graphql/mutations/security/ci_configuration/configure_sast_iac_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ConfigureSastIac' do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :test_repo) } + + let(:variables) { { project_path: project.full_path } } + let(:mutation) { graphql_mutation(:configure_sast_iac, variables) } + let(:mutation_response) { graphql_mutation_response(:configureSastIac) } + + context 'when authorized' do + let_it_be(:user) { project.owner } + + it 'creates a branch with sast iac configured' do + post_graphql_mutation(mutation, current_user: user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to be_empty + expect(mutation_response['branch']).not_to be_empty + expect(mutation_response['successPath']).not_to be_empty + end + end +end diff --git a/spec/requests/api/graphql/namespace_query_spec.rb b/spec/requests/api/graphql/namespace_query_spec.rb new file mode 100644 index 00000000000..f7ee2bcb55d --- /dev/null +++ b/spec/requests/api/graphql/namespace_query_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query' do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:other_user) { create(:user) } + + let_it_be(:group_namespace) { create(:group) } + let_it_be(:user_namespace) { create(:user_namespace, owner: user) } + let_it_be(:project_namespace) { create(:project_namespace, parent: group_namespace) } + + describe '.namespace' do + subject { post_graphql(query, current_user: current_user) } + + let(:current_user) { user } + + let(:query) { graphql_query_for(:namespace, { 'fullPath' => target_namespace.full_path }, all_graphql_fields_for('Namespace')) } + let(:query_result) { graphql_data['namespace'] } + + shared_examples 'retrieving a namespace' do + context 'authorised query' do + before do + subject + end + + it_behaves_like 'a working graphql query' + + it 'fetches the expected data' do + expect(query_result).to include( + 'fullPath' => target_namespace.full_path, + 'name' => target_namespace.name + ) + end + end + + context 'unauthorised query' do + before do + subject + end + + context 'anonymous user' do + let(:current_user) { nil } + + it 'does not retrieve the record' do + expect(query_result).to be_nil + end + end + + context 'the current user does not have permission' do + let(:current_user) { other_user } + + it 'does not retrieve the record' do + expect(query_result).to be_nil + end + end + end + end + + it_behaves_like 'retrieving a namespace' do + let(:target_namespace) { group_namespace } + + before do + group_namespace.add_developer(user) + end + end + + it_behaves_like 'retrieving a namespace' do + let(:target_namespace) { user_namespace } + end + + context 'does not retrieve project namespace' do + let(:target_namespace) { project_namespace } + + before do + subject + end + + it 'does not retrieve the record' do + expect(query_result).to be_nil + end + end + end +end diff --git a/spec/requests/api/graphql/packages/helm_spec.rb b/spec/requests/api/graphql/packages/helm_spec.rb new file mode 100644 index 00000000000..397096f70db --- /dev/null +++ b/spec/requests/api/graphql/packages/helm_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'helm package details' do + include GraphqlHelpers + include_context 'package details setup' + + let_it_be(:package) { create(:helm_package, project: project) } + + let(:package_files_metadata) {query_graphql_fragment('HelmFileMetadata')} + + let(:query) do + graphql_query_for(:package, { id: package_global_id }, <<~FIELDS) + #{all_graphql_fields_for('PackageDetailsType', max_depth: depth, excluded: excluded)} + packageFiles { + nodes { + #{package_files} + fileMetadata { + #{package_files_metadata} + } + } + } + FIELDS + end + + subject { post_graphql(query, current_user: user) } + + before do + subject + end + + it_behaves_like 'a package detail' + it_behaves_like 'a package with files' + + it 'has the correct file metadata' do + expect(first_file_response_metadata).to include( + 'channel' => first_file.helm_file_metadatum.channel + ) + expect(first_file_response_metadata['metadata']).to include( + 'name' => first_file.helm_file_metadatum.metadata['name'], + 'home' => first_file.helm_file_metadatum.metadata['home'], + 'sources' => first_file.helm_file_metadatum.metadata['sources'], + 'version' => first_file.helm_file_metadatum.metadata['version'], + 'description' => first_file.helm_file_metadatum.metadata['description'], + 'keywords' => first_file.helm_file_metadatum.metadata['keywords'], + 'maintainers' => first_file.helm_file_metadatum.metadata['maintainers'], + 'icon' => first_file.helm_file_metadatum.metadata['icon'], + 'apiVersion' => first_file.helm_file_metadatum.metadata['apiVersion'], + 'condition' => first_file.helm_file_metadatum.metadata['condition'], + 'tags' => first_file.helm_file_metadatum.metadata['tags'], + 'appVersion' => first_file.helm_file_metadatum.metadata['appVersion'], + 'deprecated' => first_file.helm_file_metadatum.metadata['deprecated'], + 'annotations' => first_file.helm_file_metadatum.metadata['annotations'], + 'kubeVersion' => first_file.helm_file_metadatum.metadata['kubeVersion'], + 'dependencies' => first_file.helm_file_metadatum.metadata['dependencies'], + 'type' => first_file.helm_file_metadatum.metadata['type'] + ) + end +end diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index 1c6d6ce4707..b3e91afb5b3 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -429,11 +429,11 @@ RSpec.describe 'getting an issue list for a project' do end it 'avoids N+1 queries' do - create(:contact, group_id: group.id, issues: [issue_a]) + create(:issue_customer_relations_contact, :for_issue, issue: issue_a) control = ActiveRecord::QueryRecorder.new(skip_cached: false) { clean_state_query } - create(:contact, group_id: group.id, issues: [issue_a]) + create(:issue_customer_relations_contact, :for_issue, issue: issue_a) expect { clean_state_query }.not_to exceed_all_query_limit(control) end diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb index 438ea9bb4c1..353bf0356f6 100644 --- a/spec/requests/api/graphql/project/merge_request_spec.rb +++ b/spec/requests/api/graphql/project/merge_request_spec.rb @@ -347,7 +347,7 @@ RSpec.describe 'getting merge request information nested in a project' do expect(interaction_data).to contain_exactly a_hash_including( 'canMerge' => false, 'canUpdate' => can_update, - 'reviewState' => unreviewed, + 'reviewState' => attention_requested, 'reviewed' => false, 'approved' => false ) @@ -380,8 +380,8 @@ RSpec.describe 'getting merge request information nested in a project' do describe 'scalability' do let_it_be(:other_users) { create_list(:user, 3) } - let(:unreviewed) do - { 'reviewState' => 'UNREVIEWED' } + let(:attention_requested) do + { 'reviewState' => 'ATTENTION_REQUESTED' } end let(:reviewed) do @@ -413,9 +413,9 @@ RSpec.describe 'getting merge request information nested in a project' do expect { post_graphql(query) }.not_to exceed_query_limit(baseline) expect(interaction_data).to contain_exactly( - include(unreviewed), - include(unreviewed), - include(unreviewed), + include(attention_requested), + include(attention_requested), + include(attention_requested), include(reviewed) ) end @@ -444,7 +444,7 @@ RSpec.describe 'getting merge request information nested in a project' do it_behaves_like 'when requesting information about MR interactions' do let(:field) { :reviewers } - let(:unreviewed) { 'UNREVIEWED' } + let(:attention_requested) { 'ATTENTION_REQUESTED' } let(:can_update) { false } def assign_user(user) @@ -454,7 +454,7 @@ RSpec.describe 'getting merge request information nested in a project' do it_behaves_like 'when requesting information about MR interactions' do let(:field) { :assignees } - let(:unreviewed) { nil } + let(:attention_requested) { nil } let(:can_update) { true } # assignees can update MRs def assign_user(user) diff --git a/spec/requests/api/graphql/project/release_spec.rb b/spec/requests/api/graphql/project/release_spec.rb index 7f24d051457..77abac4ef04 100644 --- a/spec/requests/api/graphql/project/release_spec.rb +++ b/spec/requests/api/graphql/project/release_spec.rb @@ -228,6 +228,189 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do end end + shared_examples 'restricted access to release fields' do + describe 'scalar fields' do + let(:path) { path_prefix } + + let(:release_fields) do + %{ + tagName + tagPath + description + descriptionHtml + name + createdAt + releasedAt + upcomingRelease + } + end + + before do + post_query + end + + it 'finds all release data' do + expect(data).to eq({ + 'tagName' => release.tag, + 'tagPath' => nil, + 'description' => release.description, + 'descriptionHtml' => release.description_html, + 'name' => release.name, + 'createdAt' => release.created_at.iso8601, + 'releasedAt' => release.released_at.iso8601, + 'upcomingRelease' => false + }) + end + end + + describe 'milestones' do + let(:path) { path_prefix + %w[milestones nodes] } + + let(:release_fields) do + query_graphql_field(:milestones, nil, 'nodes { id title }') + end + + it 'finds milestones associated to a release' do + post_query + + expected = release.milestones.order_by_dates_and_title.map do |milestone| + { 'id' => global_id_of(milestone), 'title' => milestone.title } + end + + expect(data).to eq(expected) + end + end + + describe 'author' do + let(:path) { path_prefix + %w[author] } + + let(:release_fields) do + query_graphql_field(:author, nil, 'id username') + end + + it 'finds the author of the release' do + post_query + + expect(data).to eq( + 'id' => global_id_of(release.author), + 'username' => release.author.username + ) + end + end + + describe 'commit' do + let(:path) { path_prefix + %w[commit] } + + let(:release_fields) do + query_graphql_field(:commit, nil, 'sha') + end + + it 'restricts commit associated with the release' do + post_query + + expect(data).to eq(nil) + end + end + + describe 'assets' do + describe 'count' do + let(:path) { path_prefix + %w[assets] } + + let(:release_fields) do + query_graphql_field(:assets, nil, 'count') + end + + it 'returns non source release links count' do + post_query + + expect(data).to eq('count' => release.assets_count(except: [:sources])) + end + end + + describe 'links' do + let(:path) { path_prefix + %w[assets links nodes] } + + let(:release_fields) do + query_graphql_field(:assets, nil, + query_graphql_field(:links, nil, 'nodes { id name url external, directAssetUrl }')) + end + + it 'finds all non source external release links' do + post_query + + expected = release.links.map do |link| + { + 'id' => global_id_of(link), + 'name' => link.name, + 'url' => link.url, + 'external' => true, + 'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << "/downloads#{link.filepath}" : link.url + } + end + + expect(data).to match_array(expected) + end + end + + describe 'sources' do + let(:path) { path_prefix + %w[assets sources nodes] } + + let(:release_fields) do + query_graphql_field(:assets, nil, + query_graphql_field(:sources, nil, 'nodes { format url }')) + end + + it 'restricts release sources' do + post_query + + expect(data).to match_array([]) + end + end + end + + describe 'links' do + let(:path) { path_prefix + %w[links] } + + let(:release_fields) do + query_graphql_field(:links, nil, %{ + selfUrl + openedMergeRequestsUrl + mergedMergeRequestsUrl + closedMergeRequestsUrl + openedIssuesUrl + closedIssuesUrl + }) + end + + it 'finds only selfUrl' do + post_query + + expect(data).to eq( + 'selfUrl' => project_release_url(project, release), + 'openedMergeRequestsUrl' => nil, + 'mergedMergeRequestsUrl' => nil, + 'closedMergeRequestsUrl' => nil, + 'openedIssuesUrl' => nil, + 'closedIssuesUrl' => nil + ) + end + end + + describe 'evidences' do + let(:path) { path_prefix + %w[evidences] } + + let(:release_fields) do + query_graphql_field(:evidences, nil, 'nodes { id sha filepath collectedAt }') + end + + it 'restricts all evidence fields' do + post_query + + expect(data).to eq('nodes' => []) + end + end + end + shared_examples 'no access to the release field' do describe 'repository-related fields' do let(:path) { path_prefix } @@ -302,7 +485,8 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do context 'when the user has Guest permissions' do let(:current_user) { guest } - it_behaves_like 'no access to the release field' + it_behaves_like 'restricted access to release fields' + it_behaves_like 'no access to editUrl' end context 'when the user has Reporter permissions' do diff --git a/spec/requests/api/graphql/project/releases_spec.rb b/spec/requests/api/graphql/project/releases_spec.rb index 2816ce90a6b..c28a6fa7666 100644 --- a/spec/requests/api/graphql/project/releases_spec.rb +++ b/spec/requests/api/graphql/project/releases_spec.rb @@ -129,10 +129,12 @@ RSpec.describe 'Query.project(fullPath).releases()' do end it 'does not return data for fields that expose repository information' do + tag_name = release.tag + release_name = release.name expect(data).to eq( - 'tagName' => nil, + 'tagName' => tag_name, 'tagPath' => nil, - 'name' => "Release-#{release.id}", + 'name' => release_name, 'commit' => nil, 'assets' => { 'count' => release.assets_count(except: [:sources]), @@ -143,7 +145,14 @@ RSpec.describe 'Query.project(fullPath).releases()' do 'evidences' => { 'nodes' => [] }, - 'links' => nil + 'links' => { + 'closedIssuesUrl' => nil, + 'closedMergeRequestsUrl' => nil, + 'mergedMergeRequestsUrl' => nil, + 'openedIssuesUrl' => nil, + 'openedMergeRequestsUrl' => nil, + 'selfUrl' => project_release_url(project, release) + } ) end end diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb index 7d182a3414b..b8f7af29a9f 100644 --- a/spec/requests/api/graphql_spec.rb +++ b/spec/requests/api/graphql_spec.rb @@ -12,21 +12,33 @@ RSpec.describe 'GraphQL' do describe 'logging' do shared_examples 'logging a graphql query' do - let(:expected_params) do + let(:expected_execute_query_log) do { - query_string: query, - variables: variables.to_s, - duration_s: anything, + "correlation_id" => kind_of(String), + "meta.caller_id" => "graphql:anonymous", + "meta.client_id" => kind_of(String), + "meta.feature_category" => "not_owned", + "meta.remote_ip" => kind_of(String), + "query_analysis.duration_s" => kind_of(Numeric), + "query_analysis.depth" => 1, + "query_analysis.complexity" => 1, + "query_analysis.used_fields" => ['Query.echo'], + "query_analysis.used_deprecated_fields" => [], + # query_fingerprint starts with operation name + query_fingerprint: %r{^anonymous\/}, + duration_s: kind_of(Numeric), + trace_type: 'execute_query', operation_name: nil, - depth: 1, - complexity: 1, - used_fields: ['Query.echo'], - used_deprecated_fields: [] + # operation_fingerprint starts with operation name + operation_fingerprint: %r{^anonymous\/}, + is_mutation: false, + variables: variables.to_s, + query_string: query } end it 'logs a query with the expected params' do - expect(Gitlab::GraphqlLogger).to receive(:info).with(expected_params).once + expect(Gitlab::GraphqlLogger).to receive(:info).with(expected_execute_query_log).once post_graphql(query, variables: variables) end diff --git a/spec/requests/api/group_debian_distributions_spec.rb b/spec/requests/api/group_debian_distributions_spec.rb index ec1912b72bf..21c5f2f09a0 100644 --- a/spec/requests/api/group_debian_distributions_spec.rb +++ b/spec/requests/api/group_debian_distributions_spec.rb @@ -11,19 +11,25 @@ RSpec.describe API::GroupDebianDistributions do let(:url) { "/groups/#{container.id}/-/debian_distributions" } let(:api_params) { { 'codename': 'my-codename' } } - it_behaves_like 'Debian repository write endpoint', 'POST distribution request', :created, /^{.*"codename":"my-codename",.*"components":\["main"\],.*"architectures":\["all","amd64"\]/, authenticate_non_public: false + it_behaves_like 'Debian distributions write endpoint', 'POST', :created, /^{.*"codename":"my-codename",.*"components":\["main"\],.*"architectures":\["all","amd64"\]/ end describe 'GET groups/:id/-/debian_distributions' do let(:url) { "/groups/#{container.id}/-/debian_distributions" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^\[{.*"codename":"existing-codename",.*"components":\["existing-component"\],.*"architectures":\["all","existing-arch"\]/, authenticate_non_public: false + it_behaves_like 'Debian distributions read endpoint', 'GET', :success, /^\[{.*"codename":"existing-codename",.*"components":\["existing-component"\],.*"architectures":\["all","existing-arch"\]/ end describe 'GET groups/:id/-/debian_distributions/:codename' do let(:url) { "/groups/#{container.id}/-/debian_distributions/#{distribution.codename}" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^{.*"codename":"existing-codename",.*"components":\["existing-component"\],.*"architectures":\["all","existing-arch"\]/, authenticate_non_public: false + it_behaves_like 'Debian distributions read endpoint', 'GET', :success, /^{.*"codename":"existing-codename",.*"components":\["existing-component"\],.*"architectures":\["all","existing-arch"\]/ + end + + describe 'GET groups/:id/-/debian_distributions/:codename/key.asc' do + let(:url) { "/groups/#{container.id}/-/debian_distributions/#{distribution.codename}/key.asc" } + + it_behaves_like 'Debian distributions read endpoint', 'GET', :success, /^-----BEGIN PGP PUBLIC KEY BLOCK-----/ end describe 'PUT groups/:id/-/debian_distributions/:codename' do @@ -31,14 +37,14 @@ RSpec.describe API::GroupDebianDistributions do let(:url) { "/groups/#{container.id}/-/debian_distributions/#{distribution.codename}" } let(:api_params) { { suite: 'my-suite' } } - it_behaves_like 'Debian repository write endpoint', 'PUT distribution request', :success, /^{.*"codename":"existing-codename",.*"suite":"my-suite",/, authenticate_non_public: false + it_behaves_like 'Debian distributions write endpoint', 'PUT', :success, /^{.*"codename":"existing-codename",.*"suite":"my-suite",/ end describe 'DELETE groups/:id/-/debian_distributions/:codename' do let(:method) { :delete } let(:url) { "/groups/#{container.id}/-/debian_distributions/#{distribution.codename}" } - it_behaves_like 'Debian repository maintainer write endpoint', 'DELETE distribution request', :success, /^{"message":"202 Accepted"}$/, authenticate_non_public: false + it_behaves_like 'Debian distributions maintainer write endpoint', 'DELETE', :success, /^{"message":"202 Accepted"}$/ end end end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index cee727ae6fe..75f5a974d22 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -319,12 +319,15 @@ RSpec.describe API::Groups do it "includes statistics if requested" do attributes = { - storage_size: 2392, + storage_size: 4093, repository_size: 123, wiki_size: 456, lfs_objects_size: 234, build_artifacts_size: 345, - snippets_size: 1234 + pipeline_artifacts_size: 456, + packages_size: 567, + snippets_size: 1234, + uploads_size: 678 }.stringify_keys exposed_attributes = attributes.dup exposed_attributes['job_artifacts_size'] = exposed_attributes.delete('build_artifacts_size') diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb index aeca4e435f4..0a71eb43f81 100644 --- a/spec/requests/api/internal/base_spec.rb +++ b/spec/requests/api/internal/base_spec.rb @@ -948,7 +948,7 @@ RSpec.describe API::Internal::Base do context 'user does not exist' do it do - pull(OpenStruct.new(id: 0), project) + pull(double('key', id: 0), project) expect(response).to have_gitlab_http_status(:not_found) expect(json_response["status"]).to be_falsey diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb index b23ba0021e0..cba4256adc5 100644 --- a/spec/requests/api/invitations_spec.rb +++ b/spec/requests/api/invitations_spec.rb @@ -166,6 +166,38 @@ RSpec.describe API::Invitations do end end + context 'with tasks_to_be_done and tasks_project_id in the params' do + before do + stub_experiments(invite_members_for_task: true) + end + + let(:project_id) { source_type == 'project' ? source.id : create(:project, namespace: source).id } + + context 'when there is 1 invitation' do + it 'creates a member_task with the tasks_to_be_done and the project' do + post invitations_url(source, maintainer), + params: { email: email, access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project_id } + + member = source.members.find_by(invite_email: email) + expect(member.tasks_to_be_done).to match_array([:code, :ci]) + expect(member.member_task.project_id).to eq(project_id) + end + end + + context 'when there are multiple invitations' do + it 'creates a member_task with the tasks_to_be_done and the project' do + post invitations_url(source, maintainer), + params: { email: [email, email2].join(','), access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project_id } + + members = source.members.where(invite_email: [email, email2]) + members.each do |member| + expect(member.tasks_to_be_done).to match_array([:code, :ci]) + expect(member.member_task.project_id).to eq(project_id) + end + end + end + end + context 'with invite_source considerations', :snowplow do let(:params) { { email: email, access_level: Member::DEVELOPER } } diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb index d7f22b9d619..ac30da99afe 100644 --- a/spec/requests/api/lint_spec.rb +++ b/spec/requests/api/lint_spec.rb @@ -102,6 +102,13 @@ RSpec.describe API::Lint do expect(response).to have_gitlab_http_status(:ok) expect(json_response).to have_key('merged_yaml') end + + it 'outputs jobs' do + post api('/ci/lint', api_user), params: { content: yaml_content, include_jobs: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to have_key('jobs') + end end context 'with valid .gitlab-ci.yaml with warnings' do @@ -136,6 +143,13 @@ RSpec.describe API::Lint do expect(response).to have_gitlab_http_status(:ok) expect(json_response).to have_key('merged_yaml') end + + it 'outputs jobs' do + post api('/ci/lint', api_user), params: { content: yaml_content, include_jobs: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to have_key('jobs') + end end context 'with invalid configuration' do @@ -156,6 +170,13 @@ RSpec.describe API::Lint do expect(response).to have_gitlab_http_status(:ok) expect(json_response).to have_key('merged_yaml') end + + it 'outputs jobs' do + post api('/ci/lint', api_user), params: { content: yaml_content, include_jobs: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to have_key('jobs') + end end end @@ -171,10 +192,11 @@ RSpec.describe API::Lint do end describe 'GET /projects/:id/ci/lint' do - subject(:ci_lint) { get api("/projects/#{project.id}/ci/lint", api_user), params: { dry_run: dry_run } } + subject(:ci_lint) { get api("/projects/#{project.id}/ci/lint", api_user), params: { dry_run: dry_run, include_jobs: include_jobs } } let(:project) { create(:project, :repository) } let(:dry_run) { nil } + let(:include_jobs) { nil } RSpec.shared_examples 'valid config with warnings' do it 'passes validation with warnings' do @@ -359,6 +381,30 @@ RSpec.describe API::Lint do it_behaves_like 'valid config without warnings' end + context 'when running with include jobs' do + let(:include_jobs) { true } + + it_behaves_like 'valid config without warnings' + + it 'returns jobs key' do + ci_lint + + expect(json_response).to have_key('jobs') + end + end + + context 'when running without include jobs' do + let(:include_jobs) { false } + + it_behaves_like 'valid config without warnings' + + it 'does not return jobs key' do + ci_lint + + expect(json_response).not_to have_key('jobs') + end + end + context 'With warnings' do let(:yaml_content) { { job: { script: 'ls', rules: [{ when: 'always' }] } }.to_yaml } @@ -386,15 +432,40 @@ RSpec.describe API::Lint do it_behaves_like 'invalid config' end + + context 'when running with include jobs' do + let(:include_jobs) { true } + + it_behaves_like 'invalid config' + + it 'returns jobs key' do + ci_lint + + expect(json_response).to have_key('jobs') + end + end + + context 'when running without include jobs' do + let(:include_jobs) { false } + + it_behaves_like 'invalid config' + + it 'does not return jobs key' do + ci_lint + + expect(json_response).not_to have_key('jobs') + end + end end end end describe 'POST /projects/:id/ci/lint' do - subject(:ci_lint) { post api("/projects/#{project.id}/ci/lint", api_user), params: { dry_run: dry_run, content: yaml_content } } + subject(:ci_lint) { post api("/projects/#{project.id}/ci/lint", api_user), params: { dry_run: dry_run, content: yaml_content, include_jobs: include_jobs } } let(:project) { create(:project, :repository) } let(:dry_run) { nil } + let(:include_jobs) { nil } let_it_be(:api_user) { create(:user) } @@ -562,6 +633,30 @@ RSpec.describe API::Lint do it_behaves_like 'valid project config' end + + context 'when running with include jobs param' do + let(:include_jobs) { true } + + it_behaves_like 'valid project config' + + it 'contains jobs key' do + ci_lint + + expect(json_response).to have_key('jobs') + end + end + + context 'when running without include jobs param' do + let(:include_jobs) { false } + + it_behaves_like 'valid project config' + + it 'does not contain jobs key' do + ci_lint + + expect(json_response).not_to have_key('jobs') + end + end end context 'with invalid .gitlab-ci.yml content' do @@ -580,6 +675,30 @@ RSpec.describe API::Lint do it_behaves_like 'invalid project config' end + + context 'when running with include jobs set to false' do + let(:include_jobs) { false } + + it_behaves_like 'invalid project config' + + it 'does not contain jobs key' do + ci_lint + + expect(json_response).not_to have_key('jobs') + end + end + + context 'when running with param include jobs' do + let(:include_jobs) { true } + + it_behaves_like 'invalid project config' + + it 'contains jobs key' do + ci_lint + + expect(json_response).to have_key('jobs') + end + end end end end diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index a1daf86de31..7f4345faabb 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -81,14 +81,22 @@ RSpec.describe API::Members do expect(json_response.map { |u| u['id'] }).to match_array [maintainer.id, developer.id] end - it 'finds members with query string' do - get api(members_url, developer), params: { query: maintainer.username } + context 'with cross db check disabled' do + around do |example| + allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/343305') do + example.run + end + end - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.count).to eq(1) - expect(json_response.first['username']).to eq(maintainer.username) + it 'finds members with query string' do + get api(members_url, developer), params: { query: maintainer.username } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.count).to eq(1) + expect(json_response.first['username']).to eq(maintainer.username) + end end it 'finds members with the given user_ids' do @@ -406,6 +414,38 @@ RSpec.describe API::Members do end end + context 'with tasks_to_be_done and tasks_project_id in the params' do + before do + stub_experiments(invite_members_for_task: true) + end + + let(:project_id) { source_type == 'project' ? source.id : create(:project, namespace: source).id } + + context 'when there is 1 user to add' do + it 'creates a member_task with the correct attributes' do + post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), + params: { user_id: stranger.id, access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project_id } + + member = source.members.find_by(user_id: stranger.id) + expect(member.tasks_to_be_done).to match_array([:code, :ci]) + expect(member.member_task.project_id).to eq(project_id) + end + end + + context 'when there are multiple users to add' do + it 'creates a member_task with the correct attributes' do + post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), + params: { user_id: [developer.id, stranger.id].join(','), access_level: Member::DEVELOPER, tasks_to_be_done: %w(code ci), tasks_project_id: project_id } + + members = source.members.where(user_id: [developer.id, stranger.id]) + members.each do |member| + expect(member.tasks_to_be_done).to match_array([:code, :ci]) + expect(member.member_task.project_id).to eq(project_id) + end + end + end + end + it "returns 409 if member already exists" do post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), params: { user_id: maintainer.id, access_level: Member::MAINTAINER } diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index bdbc73a59d8..7c147419354 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -3278,6 +3278,8 @@ RSpec.describe API::MergeRequests do context 'when skip_ci parameter is set' do it 'enqueues a rebase of the merge request with skip_ci flag set' do + allow(RebaseWorker).to receive(:with_status).and_return(RebaseWorker) + expect(RebaseWorker).to receive(:perform_async).with(merge_request.id, user.id, true).and_call_original Sidekiq::Testing.fake! do diff --git a/spec/requests/api/namespaces_spec.rb b/spec/requests/api/namespaces_spec.rb index 222d8992d1b..01dbf523071 100644 --- a/spec/requests/api/namespaces_spec.rb +++ b/spec/requests/api/namespaces_spec.rb @@ -3,10 +3,12 @@ require 'spec_helper' RSpec.describe API::Namespaces do - let(:admin) { create(:admin) } - let(:user) { create(:user) } - let!(:group1) { create(:group, name: 'group.one') } - let!(:group2) { create(:group, :nested) } + let_it_be(:admin) { create(:admin) } + let_it_be(:user) { create(:user) } + let_it_be(:group1) { create(:group, name: 'group.one') } + let_it_be(:group2) { create(:group, :nested) } + let_it_be(:project) { create(:project, namespace: group2, name: group2.name, path: group2.path) } + let_it_be(:project_namespace) { project.project_namespace } describe "GET /namespaces" do context "when unauthenticated" do @@ -26,7 +28,7 @@ RSpec.describe API::Namespaces do expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(group_kind_json_response.keys).to include('id', 'kind', 'name', 'path', 'full_path', - 'parent_id', 'members_count_with_descendants') + 'parent_id', 'members_count_with_descendants') expect(user_kind_json_response.keys).to include('id', 'kind', 'name', 'path', 'full_path', 'parent_id') end @@ -37,7 +39,8 @@ RSpec.describe API::Namespaces do expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(Namespace.count) + # project namespace is excluded + expect(json_response.length).to eq(Namespace.count - 1) end it "admin: returns an array of matched namespaces" do @@ -61,7 +64,7 @@ RSpec.describe API::Namespaces do owned_group_response = json_response.find { |resource| resource['id'] == group1.id } expect(owned_group_response.keys).to include('id', 'kind', 'name', 'path', 'full_path', - 'parent_id', 'members_count_with_descendants') + 'parent_id', 'members_count_with_descendants') end it "returns correct attributes when user cannot admin group" do @@ -109,7 +112,8 @@ RSpec.describe API::Namespaces do describe 'GET /namespaces/:id' do let(:owned_group) { group1 } - let(:user2) { create(:user) } + + let_it_be(:user2) { create(:user) } shared_examples 'can access namespace' do it 'returns namespace details' do @@ -144,6 +148,16 @@ RSpec.describe API::Namespaces do it_behaves_like 'can access namespace' end + + context 'when requesting project_namespace' do + let(:namespace_id) { project_namespace.id } + + it 'returns not-found' do + get api("/namespaces/#{namespace_id}", request_actor) + + expect(response).to have_gitlab_http_status(:not_found) + end + end end context 'when requested by path' do @@ -159,6 +173,16 @@ RSpec.describe API::Namespaces do it_behaves_like 'can access namespace' end + + context 'when requesting project_namespace' do + let(:namespace_id) { project_namespace.full_path } + + it 'returns not-found' do + get api("/namespaces/#{namespace_id}", request_actor) + + expect(response).to have_gitlab_http_status(:not_found) + end + end end end @@ -177,6 +201,12 @@ RSpec.describe API::Namespaces do expect(response).to have_gitlab_http_status(:unauthorized) end + + it 'returns authentication error' do + get api("/namespaces/#{project_namespace.id}") + + expect(response).to have_gitlab_http_status(:unauthorized) + end end context 'when authenticated as regular user' do @@ -231,10 +261,10 @@ RSpec.describe API::Namespaces do end describe 'GET /namespaces/:namespace/exists' do - let!(:namespace1) { create(:group, name: 'Namespace 1', path: 'namespace-1') } - let!(:namespace2) { create(:group, name: 'Namespace 2', path: 'namespace-2') } - let!(:namespace1sub) { create(:group, name: 'Sub Namespace 1', path: 'sub-namespace-1', parent: namespace1) } - let!(:namespace2sub) { create(:group, name: 'Sub Namespace 2', path: 'sub-namespace-2', parent: namespace2) } + let_it_be(:namespace1) { create(:group, name: 'Namespace 1', path: 'namespace-1') } + let_it_be(:namespace2) { create(:group, name: 'Namespace 2', path: 'namespace-2') } + let_it_be(:namespace1sub) { create(:group, name: 'Sub Namespace 1', path: 'sub-namespace-1', parent: namespace1) } + let_it_be(:namespace2sub) { create(:group, name: 'Sub Namespace 2', path: 'sub-namespace-2', parent: namespace2) } context 'when unauthenticated' do it 'returns authentication error' do @@ -242,6 +272,16 @@ RSpec.describe API::Namespaces do expect(response).to have_gitlab_http_status(:unauthorized) end + + context 'when requesting project_namespace' do + let(:namespace_id) { project_namespace.id } + + it 'returns authentication error' do + get api("/namespaces/#{project_namespace.path}/exists"), params: { parent_id: group2.id } + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end end context 'when authenticated' do @@ -300,6 +340,18 @@ RSpec.describe API::Namespaces do expect(response).to have_gitlab_http_status(:ok) expect(response.body).to eq(expected_json) end + + context 'when requesting project_namespace' do + let(:namespace_id) { project_namespace.id } + + it 'returns JSON indicating the namespace does not exist without a suggestion' do + get api("/namespaces/#{project_namespace.path}/exists", user), params: { parent_id: group2.id } + + expected_json = { exists: false, suggests: [] }.to_json + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to eq(expected_json) + end + end end end end diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb index 0d04c2cad5b..7c3f1890095 100644 --- a/spec/requests/api/npm_project_packages_spec.rb +++ b/spec/requests/api/npm_project_packages_spec.rb @@ -180,6 +180,7 @@ RSpec.describe API::NpmProjectPackages do .to change { project.packages.count }.by(1) .and change { Packages::PackageFile.count }.by(1) .and change { Packages::Tag.count }.by(1) + .and change { Packages::Npm::Metadatum.count }.by(1) expect(response).to have_gitlab_http_status(:ok) end @@ -317,6 +318,25 @@ RSpec.describe API::NpmProjectPackages do end end end + + context 'with a too large metadata structure' do + let(:package_name) { "@#{group.path}/my_package_name" } + let(:params) do + upload_params(package_name: package_name, package_version: '1.2.3').tap do |h| + h['versions']['1.2.3']['test'] = 'test' * 10000 + end + end + + it_behaves_like 'not a package tracking event' + + it 'returns an error' do + expect { upload_package_with_token } + .not_to change { project.packages.count } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.body).to include('Validation failed: Package json structure is too large') + end + end end def upload_package(package_name, params = {}) diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index dd00d413664..01d2fb18f00 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -137,6 +137,7 @@ project_setting: unexposed_attributes: - created_at - has_confluence + - has_shimo - has_vulnerabilities - prevent_merge_without_jira_issue - warn_about_potentially_unwanted_characters diff --git a/spec/requests/api/project_debian_distributions_spec.rb b/spec/requests/api/project_debian_distributions_spec.rb index de7362758f7..2b993f24046 100644 --- a/spec/requests/api/project_debian_distributions_spec.rb +++ b/spec/requests/api/project_debian_distributions_spec.rb @@ -11,25 +11,31 @@ RSpec.describe API::ProjectDebianDistributions do let(:url) { "/projects/#{container.id}/debian_distributions" } let(:api_params) { { 'codename': 'my-codename' } } - it_behaves_like 'Debian repository write endpoint', 'POST distribution request', :created, /^{.*"codename":"my-codename",.*"components":\["main"\],.*"architectures":\["all","amd64"\]/, authenticate_non_public: false + it_behaves_like 'Debian distributions write endpoint', 'POST', :created, /^{.*"codename":"my-codename",.*"components":\["main"\],.*"architectures":\["all","amd64"\]/ context 'with invalid parameters' do let(:api_params) { { codename: distribution.codename } } - it_behaves_like 'Debian repository write endpoint', 'GET request', :bad_request, /^{"message":{"codename":\["has already been taken"\]}}$/, authenticate_non_public: false + it_behaves_like 'Debian distributions write endpoint', 'GET', :bad_request, /^{"message":{"codename":\["has already been taken"\]}}$/ end end describe 'GET projects/:id/debian_distributions' do let(:url) { "/projects/#{container.id}/debian_distributions" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^\[{.*"codename":"existing-codename\",.*"components":\["existing-component"\],.*"architectures":\["all","existing-arch"\]/, authenticate_non_public: false + it_behaves_like 'Debian distributions read endpoint', 'GET', :success, /^\[{.*"codename":"existing-codename\",.*"components":\["existing-component"\],.*"architectures":\["all","existing-arch"\]/ end describe 'GET projects/:id/debian_distributions/:codename' do let(:url) { "/projects/#{container.id}/debian_distributions/#{distribution.codename}" } - it_behaves_like 'Debian repository read endpoint', 'GET request', :success, /^{.*"codename":"existing-codename\",.*"components":\["existing-component"\],.*"architectures":\["all","existing-arch"\]/, authenticate_non_public: false + it_behaves_like 'Debian distributions read endpoint', 'GET', :success, /^{.*"codename":"existing-codename\",.*"components":\["existing-component"\],.*"architectures":\["all","existing-arch"\]/ + end + + describe 'GET projects/:id/debian_distributions/:codename/key.asc' do + let(:url) { "/projects/#{container.id}/debian_distributions/#{distribution.codename}/key.asc" } + + it_behaves_like 'Debian distributions read endpoint', 'GET', :success, /^-----BEGIN PGP PUBLIC KEY BLOCK-----/ end describe 'PUT projects/:id/debian_distributions/:codename' do @@ -37,12 +43,12 @@ RSpec.describe API::ProjectDebianDistributions do let(:url) { "/projects/#{container.id}/debian_distributions/#{distribution.codename}" } let(:api_params) { { suite: 'my-suite' } } - it_behaves_like 'Debian repository write endpoint', 'PUT distribution request', :success, /^{.*"codename":"existing-codename",.*"suite":"my-suite",/, authenticate_non_public: false + it_behaves_like 'Debian distributions write endpoint', 'PUT', :success, /^{.*"codename":"existing-codename",.*"suite":"my-suite",/ context 'with invalid parameters' do let(:api_params) { { suite: distribution.codename } } - it_behaves_like 'Debian repository write endpoint', 'GET request', :bad_request, /^{"message":{"suite":\["has already been taken as Codename"\]}}$/, authenticate_non_public: false + it_behaves_like 'Debian distributions write endpoint', 'GET', :bad_request, /^{"message":{"suite":\["has already been taken as Codename"\]}}$/ end end @@ -50,7 +56,7 @@ RSpec.describe API::ProjectDebianDistributions do let(:method) { :delete } let(:url) { "/projects/#{container.id}/debian_distributions/#{distribution.codename}" } - it_behaves_like 'Debian repository maintainer write endpoint', 'DELETE distribution request', :success, /^{\"message\":\"202 Accepted\"}$/, authenticate_non_public: false + it_behaves_like 'Debian distributions maintainer write endpoint', 'DELETE', :success, /^{\"message\":\"202 Accepted\"}$/ context 'when destroy fails' do before do @@ -59,7 +65,7 @@ RSpec.describe API::ProjectDebianDistributions do end end - it_behaves_like 'Debian repository maintainer write endpoint', 'GET request', :bad_request, /^{"message":"Failed to delete distribution"}$/, authenticate_non_public: false + it_behaves_like 'Debian distributions maintainer write endpoint', 'GET', :bad_request, /^{"message":"Failed to delete distribution"}$/ end end end diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb index 0c9e125cc90..097d374640c 100644 --- a/spec/requests/api/project_import_spec.rb +++ b/spec/requests/api/project_import_spec.rb @@ -47,7 +47,7 @@ RSpec.describe API::ProjectImport do it 'executes a limited number of queries' do control_count = ActiveRecord::QueryRecorder.new { subject }.count - expect(control_count).to be <= 100 + expect(control_count).to be <= 101 end it 'schedules an import using a namespace' do diff --git a/spec/requests/api/project_snapshots_spec.rb b/spec/requests/api/project_snapshots_spec.rb index f23e374407b..33c86d56ed4 100644 --- a/spec/requests/api/project_snapshots_spec.rb +++ b/spec/requests/api/project_snapshots_spec.rb @@ -29,6 +29,7 @@ RSpec.describe API::ProjectSnapshots do repository: repository.gitaly_repository ).to_json ) + expect(response.parsed_body).to be_empty end it 'returns authentication error as project owner' do diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb index 8cd1f15a88d..512cbf7c321 100644 --- a/spec/requests/api/project_snippets_spec.rb +++ b/spec/requests/api/project_snippets_spec.rb @@ -400,6 +400,7 @@ RSpec.describe API::ProjectSnippets do expect(response).to have_gitlab_http_status(:ok) expect(response.media_type).to eq 'text/plain' + expect(response.parsed_body).to be_empty end it 'returns 404 for invalid snippet id' do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index dd6afa869e0..4f84e6f2562 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -48,6 +48,7 @@ end RSpec.describe API::Projects do include ProjectForksHelper + include StubRequests let_it_be(:user) { create(:user) } let_it_be(:user2) { create(:user) } @@ -358,7 +359,7 @@ RSpec.describe API::Projects do statistics = json_response.find { |p| p['id'] == project.id }['statistics'] expect(statistics).to be_present - expect(statistics).to include('commit_count', 'storage_size', 'repository_size', 'wiki_size', 'lfs_objects_size', 'job_artifacts_size', 'snippets_size', 'packages_size') + expect(statistics).to include('commit_count', 'storage_size', 'repository_size', 'wiki_size', 'lfs_objects_size', 'job_artifacts_size', 'pipeline_artifacts_size', 'snippets_size', 'packages_size', 'uploads_size') end it "does not include license by default" do @@ -1159,6 +1160,34 @@ RSpec.describe API::Projects do expect(response).to have_gitlab_http_status(:forbidden) end + it 'disallows creating a project with an import_url that is not reachable', :aggregate_failures do + url = 'http://example.com' + endpoint_url = "#{url}/info/refs?service=git-upload-pack" + stub_full_request(endpoint_url, method: :get).to_return({ status: 301, body: '', headers: nil }) + project_params = { import_url: url, path: 'path-project-Foo', name: 'Foo Project' } + + expect { post api('/projects', user), params: project_params }.not_to change { Project.count } + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to eq("#{url} is not a valid HTTP Git repository") + end + + it 'creates a project with an import_url that is valid', :aggregate_failures do + url = 'http://example.com' + endpoint_url = "#{url}/info/refs?service=git-upload-pack" + git_response = { + status: 200, + body: '001e# service=git-upload-pack', + headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' } + } + stub_full_request(endpoint_url, method: :get).to_return(git_response) + project_params = { import_url: url, path: 'path-project-Foo', name: 'Foo Project' } + + expect { post api('/projects', user), params: project_params }.to change { Project.count }.by(1) + + expect(response).to have_gitlab_http_status(:created) + end + it 'sets a project as public' do project = attributes_for(:project, visibility: 'public') diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb index 90b03a480a8..cb9b6a072b1 100644 --- a/spec/requests/api/releases_spec.rb +++ b/spec/requests/api/releases_spec.rb @@ -42,6 +42,14 @@ RSpec.describe API::Releases do expect(response).to have_gitlab_http_status(:ok) end + it 'returns 200 HTTP status when using JOB-TOKEN auth' do + job = create(:ci_build, :running, project: project, user: maintainer) + + get api("/projects/#{project.id}/releases"), params: { job_token: job.token } + + expect(response).to have_gitlab_http_status(:ok) + end + it 'returns releases ordered by released_at' do get api("/projects/#{project.id}/releases", maintainer) @@ -316,6 +324,14 @@ RSpec.describe API::Releases do expect(response).to have_gitlab_http_status(:ok) end + it 'returns 200 HTTP status when using JOB-TOKEN auth' do + job = create(:ci_build, :running, project: project, user: maintainer) + + get api("/projects/#{project.id}/releases/v0.1"), params: { job_token: job.token } + + expect(response).to have_gitlab_http_status(:ok) + end + it 'returns a release entry' do get api("/projects/#{project.id}/releases/v0.1", maintainer) @@ -1008,6 +1024,14 @@ RSpec.describe API::Releases do expect(response).to have_gitlab_http_status(:ok) end + it 'accepts the request when using JOB-TOKEN auth' do + job = create(:ci_build, :running, project: project, user: maintainer) + + put api("/projects/#{project.id}/releases/v0.1"), params: params.merge(job_token: job.token) + + expect(response).to have_gitlab_http_status(:ok) + end + it 'updates the description' do put api("/projects/#{project.id}/releases/v0.1", maintainer), params: params @@ -1220,6 +1244,14 @@ RSpec.describe API::Releases do expect(response).to have_gitlab_http_status(:ok) end + it 'accepts the request when using JOB-TOKEN auth' do + job = create(:ci_build, :running, project: project, user: maintainer) + + delete api("/projects/#{project.id}/releases/v0.1"), params: { job_token: job.token } + + expect(response).to have_gitlab_http_status(:ok) + end + it 'destroys the release' do expect do delete api("/projects/#{project.id}/releases/v0.1", maintainer) diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index f05f125c974..f3146480be2 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -197,6 +197,7 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) expect(headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + expect(response.parsed_body).to be_empty end it 'sets inline content disposition by default' do @@ -274,6 +275,7 @@ RSpec.describe API::Repositories do expect(type).to eq('git-archive') expect(params['ArchivePath']).to match(/#{project.path}\-[^\.]+\.tar.gz/) + expect(response.parsed_body).to be_empty end it 'returns the repository archive archive.zip' do @@ -495,6 +497,43 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:not_found) end + + it "returns a newly created commit", :use_clean_rails_redis_caching do + # Parse the commits ourselves because json_response is cached + def commit_messages(response) + Gitlab::Json.parse(response.body)["commits"].map do |commit| + commit["message"] + end + end + + # First trigger the rate limit cache + get api(route, current_user), params: { from: 'master', to: 'feature' } + + expect(response).to have_gitlab_http_status(:ok) + expect(commit_messages(response)).not_to include("Cool new commit") + + # Then create a new commit via the API + post api("/projects/#{project.id}/repository/commits", user), params: { + branch: "feature", + commit_message: "Cool new commit", + actions: [ + { + action: "create", + file_path: "foo/bar/baz.txt", + content: "puts 8" + } + ] + } + + expect(response).to have_gitlab_http_status(:created) + + # Now perform the same query as before, but the cache should have expired + # and our new commit should exist + get api(route, current_user), params: { from: 'master', to: 'feature' } + + expect(response).to have_gitlab_http_status(:ok) + expect(commit_messages(response)).to include("Cool new commit") + end end context 'when unauthenticated', 'and project is public' do diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 423e19c3971..641c6a2cd91 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -612,5 +612,46 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response.slice(*settings.keys)).to eq(settings) end end + + context 'Sentry settings' do + let(:settings) do + { + sentry_enabled: true, + sentry_dsn: 'http://sentry.example.com', + sentry_clientside_dsn: 'http://sentry.example.com', + sentry_environment: 'production' + } + end + + let(:attribute_names) { settings.keys.map(&:to_s) } + + it 'includes the attributes in the API' do + get api('/application/settings', admin) + + expect(response).to have_gitlab_http_status(:ok) + attribute_names.each do |attribute| + expect(json_response.keys).to include(attribute) + end + end + + it 'allows updating the settings' do + put api('/application/settings', admin), params: settings + + expect(response).to have_gitlab_http_status(:ok) + settings.each do |attribute, value| + expect(ApplicationSetting.current.public_send(attribute)).to eq(value) + end + end + + context 'missing sentry_dsn value when sentry_enabled is true' do + it 'returns a blank parameter error message' do + put api('/application/settings', admin), params: { sentry_enabled: true } + + expect(response).to have_gitlab_http_status(:bad_request) + message = json_response['message'] + expect(message["sentry_dsn"]).to include(a_string_matching("can't be blank")) + end + end + end end end diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb index f4d15d0525e..dd5e6ac8a5e 100644 --- a/spec/requests/api/snippets_spec.rb +++ b/spec/requests/api/snippets_spec.rb @@ -113,6 +113,7 @@ RSpec.describe API::Snippets, factory_default: :keep do expect(response).to have_gitlab_http_status(:ok) expect(response.media_type).to eq 'text/plain' expect(headers['Content-Disposition']).to match(/^inline/) + expect(response.parsed_body).to be_empty end it 'returns 404 for invalid snippet id' do diff --git a/spec/requests/api/tags_spec.rb b/spec/requests/api/tags_spec.rb index 1aa1ad87be9..bb56192a2ff 100644 --- a/spec/requests/api/tags_spec.rb +++ b/spec/requests/api/tags_spec.rb @@ -17,6 +17,10 @@ RSpec.describe API::Tags do end describe 'GET /projects/:id/repository/tags' do + before do + stub_feature_flags(tag_list_keyset_pagination: false) + end + shared_examples "get repository tags" do let(:route) { "/projects/#{project_id}/repository/tags" } @@ -143,6 +147,55 @@ RSpec.describe API::Tags do expect(expected_tag['release']['description']).to eq(description) end end + + context 'with keyset pagination on', :aggregate_errors do + before do + stub_feature_flags(tag_list_keyset_pagination: true) + end + + context 'with keyset pagination option' do + let(:base_params) { { pagination: 'keyset' } } + + context 'with gitaly pagination params' do + context 'with high limit' do + let(:params) { base_params.merge(per_page: 100) } + + it 'returns all repository tags' do + get api(route, user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/tags') + expect(response.headers).not_to include('Link') + tag_names = json_response.map { |x| x['name'] } + expect(tag_names).to match_array(project.repository.tag_names) + end + end + + context 'with low limit' do + let(:params) { base_params.merge(per_page: 2) } + + it 'returns limited repository tags' do + get api(route, user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/tags') + expect(response.headers).to include('Link') + tag_names = json_response.map { |x| x['name'] } + expect(tag_names).to match_array(%w(v1.1.0 v1.1.1)) + end + end + + context 'with missing page token' do + let(:params) { base_params.merge(page_token: 'unknown') } + + it_behaves_like '422 response' do + let(:request) { get api(route, user), params: params } + let(:message) { 'Invalid page token: refs/tags/unknown' } + end + end + end + end + end end context ":api_caching_tags flag enabled", :use_clean_rails_memory_store_caching do @@ -208,6 +261,20 @@ RSpec.describe API::Tags do it_behaves_like "get repository tags" end + + context 'when gitaly is unavailable' do + let(:route) { "/projects/#{project_id}/repository/tags" } + + before do + expect_next_instance_of(TagsFinder) do |finder| + allow(finder).to receive(:execute).and_raise(Gitlab::Git::CommandError) + end + end + + it_behaves_like '503 response' do + let(:request) { get api(route, user) } + end + end end describe 'GET /projects/:id/repository/tags/:tag_name' do diff --git a/spec/requests/api/terraform/modules/v1/packages_spec.rb b/spec/requests/api/terraform/modules/v1/packages_spec.rb index b04f5ad9a94..b17bc11a451 100644 --- a/spec/requests/api/terraform/modules/v1/packages_spec.rb +++ b/spec/requests/api/terraform/modules/v1/packages_spec.rb @@ -28,10 +28,25 @@ RSpec.describe API::Terraform::Modules::V1::Packages do describe 'GET /api/v4/packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/versions' do let(:url) { api("/packages/terraform/modules/v1/#{group.path}/#{package.name}/versions") } - let(:headers) { {} } + let(:headers) { { 'Authorization' => "Bearer #{tokens[:job_token]}" } } subject { get(url, headers: headers) } + context 'with a conflicting package name' do + let!(:conflicting_package) { create(:terraform_module_package, project: project, name: "conflict-#{package.name}", version: '2.0.0') } + + before do + group.add_developer(user) + end + + it 'returns only one version' do + subject + + expect(json_response['modules'][0]['versions'].size).to eq(1) + expect(json_response['modules'][0]['versions'][0]['version']).to eq('1.0.0') + end + end + context 'with valid namespace' do where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do :public | :developer | true | :personal_access_token | true | 'returns terraform module packages' | :success diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb index d31f571e636..c9deb84ff98 100644 --- a/spec/requests/api/todos_spec.rb +++ b/spec/requests/api/todos_spec.rb @@ -13,6 +13,8 @@ RSpec.describe API::Todos do let_it_be(:john_doe) { create(:user, username: 'john_doe') } let_it_be(:issue) { create(:issue, project: project_1) } let_it_be(:merge_request) { create(:merge_request, source_project: project_1) } + let_it_be(:alert) { create(:alert_management_alert, project: project_1) } + let_it_be(:alert_todo) { create(:todo, project: project_1, author: john_doe, user: john_doe, target: alert) } let_it_be(:merge_request_todo) { create(:todo, project: project_1, author: author_2, user: john_doe, target: merge_request) } let_it_be(:pending_1) { create(:todo, :mentioned, project: project_1, author: author_1, user: john_doe, target: issue) } let_it_be(:pending_2) { create(:todo, project: project_2, author: author_2, user: john_doe, target: issue) } @@ -67,7 +69,7 @@ RSpec.describe API::Todos do expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(4) + expect(json_response.length).to eq(5) expect(json_response[0]['id']).to eq(pending_3.id) expect(json_response[0]['project']).to be_a Hash expect(json_response[0]['author']).to be_a Hash @@ -95,6 +97,10 @@ RSpec.describe API::Todos do expect(json_response[3]['target']['merge_requests_count']).to be_nil expect(json_response[3]['target']['upvotes']).to eq(1) expect(json_response[3]['target']['downvotes']).to eq(0) + + expect(json_response[4]['target_type']).to eq('AlertManagement::Alert') + expect(json_response[4]['target']['iid']).to eq(alert.iid) + expect(json_response[4]['target']['title']).to eq(alert.title) end context "when current user does not have access to one of the TODO's target" do @@ -105,7 +111,7 @@ RSpec.describe API::Todos do get api('/todos', john_doe) - expect(json_response.count).to eq(4) + expect(json_response.count).to eq(5) expect(json_response.map { |t| t['id'] }).not_to include(no_access_todo.id, pending_4.id) end end @@ -163,7 +169,7 @@ RSpec.describe API::Todos do expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(3) + expect(json_response.length).to eq(4) end end diff --git a/spec/requests/api/topics_spec.rb b/spec/requests/api/topics_spec.rb new file mode 100644 index 00000000000..a5746a4022e --- /dev/null +++ b/spec/requests/api/topics_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Topics do + include WorkhorseHelpers + + let_it_be(:topic_1) { create(:topic, name: 'Git', total_projects_count: 1) } + let_it_be(:topic_2) { create(:topic, name: 'GitLab', total_projects_count: 2) } + let_it_be(:topic_3) { create(:topic, name: 'other-topic', total_projects_count: 3) } + + let_it_be(:admin) { create(:user, :admin) } + let_it_be(:user) { create(:user) } + + let(:file) { fixture_file_upload('spec/fixtures/dk.png') } + + describe 'GET /topics', :aggregate_failures do + it 'returns topics ordered by total_projects_count' do + get api('/topics') + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + + expect(json_response[0]['id']).to eq(topic_3.id) + expect(json_response[0]['name']).to eq('other-topic') + expect(json_response[0]['total_projects_count']).to eq(3) + + expect(json_response[1]['id']).to eq(topic_2.id) + expect(json_response[1]['name']).to eq('GitLab') + expect(json_response[1]['total_projects_count']).to eq(2) + + expect(json_response[2]['id']).to eq(topic_1.id) + expect(json_response[2]['name']).to eq('Git') + expect(json_response[2]['total_projects_count']).to eq(1) + end + + context 'with search' do + using RSpec::Parameterized::TableSyntax + + where(:search, :result) do + '' | %w[other-topic GitLab Git] + 'g' | %w[] + 'gi' | %w[] + 'git' | %w[Git GitLab] + 'x' | %w[] + 0 | %w[] + end + + with_them do + it 'returns filtered topics' do + get api('/topics'), params: { search: search } + + expect(json_response.map { |t| t['name'] }).to eq(result) + end + end + end + + context 'with pagination' do + using RSpec::Parameterized::TableSyntax + + where(:params, :result) do + { page: 0 } | %w[other-topic GitLab Git] + { page: 1 } | %w[other-topic GitLab Git] + { page: 2 } | %w[] + { per_page: 1 } | %w[other-topic] + { per_page: 2 } | %w[other-topic GitLab] + { per_page: 3 } | %w[other-topic GitLab Git] + { page: 0, per_page: 1 } | %w[other-topic] + { page: 0, per_page: 2 } | %w[other-topic GitLab] + { page: 1, per_page: 1 } | %w[other-topic] + { page: 1, per_page: 2 } | %w[other-topic GitLab] + { page: 2, per_page: 1 } | %w[GitLab] + { page: 2, per_page: 2 } | %w[Git] + { page: 3, per_page: 1 } | %w[Git] + { page: 3, per_page: 2 } | %w[] + { page: 4, per_page: 1 } | %w[] + { page: 4, per_page: 2 } | %w[] + end + + with_them do + it 'returns paginated topics' do + get api('/topics'), params: params + + expect(json_response.map { |t| t['name'] }).to eq(result) + end + end + end + end + + describe 'GET /topic/:id', :aggregate_failures do + it 'returns topic' do + get api("/topics/#{topic_2.id}") + + expect(response).to have_gitlab_http_status(:ok) + + expect(json_response['id']).to eq(topic_2.id) + expect(json_response['name']).to eq('GitLab') + expect(json_response['total_projects_count']).to eq(2) + end + + it 'returns 404 for non existing id' do + get api("/topics/#{non_existing_record_id}") + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 400 for invalid `id` parameter' do + get api('/topics/invalid') + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eql('id is invalid') + end + end + + describe 'POST /topics', :aggregate_failures do + context 'as administrator' do + it 'creates a topic' do + post api('/topics/', admin), params: { name: 'my-topic' } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['name']).to eq('my-topic') + expect(Projects::Topic.find(json_response['id']).name).to eq('my-topic') + end + + it 'creates a topic with avatar and description' do + workhorse_form_with_file( + api('/topics/', admin), + file_key: :avatar, + params: { name: 'my-topic', description: 'my description...', avatar: file } + ) + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['description']).to eq('my description...') + expect(json_response['avatar_url']).to end_with('dk.png') + end + + it 'returns 400 if name is missing' do + post api('/topics/', admin) + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eql('name is missing') + end + end + + context 'as normal user' do + it 'returns 403 Forbidden' do + post api('/topics/', user), params: { name: 'my-topic' } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'as anonymous' do + it 'returns 401 Unauthorized' do + post api('/topics/'), params: { name: 'my-topic' } + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + describe 'PUT /topics', :aggregate_failures do + context 'as administrator' do + it 'updates a topic' do + put api("/topics/#{topic_3.id}", admin), params: { name: 'my-topic' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('my-topic') + expect(topic_3.reload.name).to eq('my-topic') + end + + it 'updates a topic with avatar and description' do + workhorse_form_with_file( + api("/topics/#{topic_3.id}", admin), + method: :put, + file_key: :avatar, + params: { description: 'my description...', avatar: file } + ) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['description']).to eq('my description...') + expect(json_response['avatar_url']).to end_with('dk.png') + end + + it 'returns 404 for non existing id' do + put api("/topics/#{non_existing_record_id}", admin), params: { name: 'my-topic' } + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 400 for invalid `id` parameter' do + put api('/topics/invalid', admin), params: { name: 'my-topic' } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eql('id is invalid') + end + end + + context 'as normal user' do + it 'returns 403 Forbidden' do + put api("/topics/#{topic_3.id}", user), params: { name: 'my-topic' } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'as anonymous' do + it 'returns 401 Unauthorized' do + put api("/topics/#{topic_3.id}"), params: { name: 'my-topic' } + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index fb01845b63a..b93df2f3bae 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1464,6 +1464,7 @@ RSpec.describe API::Users do credit_card_expiration_year: expiration_year, credit_card_expiration_month: 1, credit_card_holder_name: 'John Smith', + credit_card_type: 'AmericanExpress', credit_card_mask_number: '1111' } end @@ -1495,6 +1496,7 @@ RSpec.describe API::Users do credit_card_validated_at: credit_card_validated_time, expiration_date: Date.new(expiration_year, 1, 31), last_digits: 1111, + network: 'AmericanExpress', holder_name: 'John Smith' ) end @@ -1904,7 +1906,8 @@ RSpec.describe API::Users do expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.first['email']).to eq(email.email) + expect(json_response.first['email']).to eq(user.email) + expect(json_response.second['email']).to eq(email.email) end it "returns a 404 for invalid ID" do @@ -2486,7 +2489,8 @@ RSpec.describe API::Users do expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.first["email"]).to eq(email.email) + expect(json_response.first['email']).to eq(user.email) + expect(json_response.second['email']).to eq(email.email) end context "scopes" do diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb index 255f53e4c7c..6d8ae226ce4 100644 --- a/spec/requests/api/v3/github_spec.rb +++ b/spec/requests/api/v3/github_spec.rb @@ -6,7 +6,7 @@ RSpec.describe API::V3::Github do let_it_be(:user) { create(:user) } let_it_be(:unauthorized_user) { create(:user) } let_it_be(:admin) { create(:user, :admin) } - let_it_be(:project) { create(:project, :repository, creator: user) } + let_it_be_with_reload(:project) { create(:project, :repository, creator: user) } before do project.add_maintainer(user) @@ -506,11 +506,18 @@ RSpec.describe API::V3::Github do describe 'GET /repos/:namespace/:project/commits/:sha' do let(:commit) { project.repository.commit } - let(:commit_id) { commit.id } + + def call_api(commit_id: commit.id) + jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", user) + end + + def response_diff_files(response) + Gitlab::Json.parse(response.body)['files'] + end context 'authenticated' do - it 'returns commit with github format' do - jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", user) + it 'returns commit with github format', :aggregate_failures do + call_api expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('entities/github/commit') @@ -519,36 +526,130 @@ RSpec.describe API::V3::Github do it 'returns 200 when project path include a dot' do project.update!(path: 'foo.bar') - jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", user) + call_api expect(response).to have_gitlab_http_status(:ok) end - it 'returns 200 when namespace path include a dot' do - group = create(:group, path: 'foo.bar') - project = create(:project, :repository, group: group) - project.add_reporter(user) + context 'when namespace path includes a dot' do + let(:group) { create(:group, path: 'foo.bar') } + let(:project) { create(:project, :repository, group: group) } - jira_get v3_api("/repos/#{group.path}/#{project.path}/commits/#{commit_id}", user) + it 'returns 200 when namespace path include a dot' do + project.add_reporter(user) - expect(response).to have_gitlab_http_status(:ok) + call_api + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when the Gitaly `CommitDiff` RPC times out', :use_clean_rails_memory_store_caching do + let(:commit_diff_args) { [project.repository_storage, :diff_service, :commit_diff, any_args] } + + before do + allow(Gitlab::GitalyClient).to receive(:call) + .and_call_original + end + + it 'handles the error, logs it, and returns empty diff files', :aggregate_failures do + allow(Gitlab::GitalyClient).to receive(:call) + .with(*commit_diff_args) + .and_raise(GRPC::DeadlineExceeded) + + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with an_instance_of(GRPC::DeadlineExceeded) + + call_api + + expect(response).to have_gitlab_http_status(:ok) + expect(response_diff_files(response)).to be_blank + end + + it 'does not handle the error when feature flag is disabled', :aggregate_failures do + stub_feature_flags(api_v3_commits_skip_diff_files: false) + + allow(Gitlab::GitalyClient).to receive(:call) + .with(*commit_diff_args) + .and_raise(GRPC::DeadlineExceeded) + + call_api + + expect(response).to have_gitlab_http_status(:error) + end + + it 'only calls Gitaly once for all attempts within a period of time', :aggregate_failures do + expect(Gitlab::GitalyClient).to receive(:call) + .with(*commit_diff_args) + .once # <- once + .and_raise(GRPC::DeadlineExceeded) + + 3.times do + call_api + + expect(response).to have_gitlab_http_status(:ok) + expect(response_diff_files(response)).to be_blank + end + end + + it 'calls Gitaly again after a period of time', :aggregate_failures do + expect(Gitlab::GitalyClient).to receive(:call) + .with(*commit_diff_args) + .twice # <- twice + .and_raise(GRPC::DeadlineExceeded) + + call_api + + expect(response).to have_gitlab_http_status(:ok) + expect(response_diff_files(response)).to be_blank + + travel_to((described_class::GITALY_TIMEOUT_CACHE_EXPIRY + 1.second).from_now) do + call_api + + expect(response).to have_gitlab_http_status(:ok) + expect(response_diff_files(response)).to be_blank + end + end + + it 'uses a unique cache key, allowing other calls to succeed' do + cache_key = [described_class::GITALY_TIMEOUT_CACHE_KEY, project.id, commit.cache_key].join(':') + Rails.cache.write(cache_key, 1) + + expect(Gitlab::GitalyClient).to receive(:call) + .with(*commit_diff_args) + .once # <- once + + call_api + + expect(response).to have_gitlab_http_status(:ok) + expect(response_diff_files(response)).to be_blank + + call_api(commit_id: commit.parent.id) + + expect(response).to have_gitlab_http_status(:ok) + expect(response_diff_files(response).length).to eq(1) + end end end context 'unauthenticated' do + let(:user) { nil } + it 'returns 401' do - jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", nil) + call_api expect(response).to have_gitlab_http_status(:unauthorized) end end context 'unauthorized' do + let(:user) { unauthorized_user } + it 'returns 404 when lower access level' do - project.add_guest(unauthorized_user) + project.add_guest(user) - jira_get v3_api("/repos/#{project.namespace.path}/#{project.path}/commits/#{commit_id}", - unauthorized_user) + call_api expect(response).to have_gitlab_http_status(:not_found) end diff --git a/spec/requests/groups/email_campaigns_controller_spec.rb b/spec/requests/groups/email_campaigns_controller_spec.rb index 4d630ef6710..9ed828d1a9a 100644 --- a/spec/requests/groups/email_campaigns_controller_spec.rb +++ b/spec/requests/groups/email_campaigns_controller_spec.rb @@ -94,7 +94,7 @@ RSpec.describe Groups::EmailCampaignsController do describe 'track parameter' do context 'when valid' do - where(track: Namespaces::InProductMarketingEmailsService::TRACKS.keys.without(:experience)) + where(track: [Namespaces::InProductMarketingEmailsService::TRACKS.keys.without(:experience), Namespaces::InviteTeamEmailService::TRACK].flatten) with_them do it_behaves_like 'track and redirect' @@ -117,6 +117,10 @@ RSpec.describe Groups::EmailCampaignsController do with_them do it_behaves_like 'track and redirect' end + + it_behaves_like 'track and redirect' do + let(:track) { Namespaces::InviteTeamEmailService::TRACK.to_s } + end end context 'when invalid' do @@ -124,6 +128,10 @@ RSpec.describe Groups::EmailCampaignsController do with_them do it_behaves_like 'no track and 404' + + it_behaves_like 'no track and 404' do + let(:track) { Namespaces::InviteTeamEmailService::TRACK.to_s } + end end end end diff --git a/spec/requests/groups/settings/applications_controller_spec.rb b/spec/requests/groups/settings/applications_controller_spec.rb new file mode 100644 index 00000000000..74313491414 --- /dev/null +++ b/spec/requests/groups/settings/applications_controller_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::Settings::ApplicationsController do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:application) { create(:oauth_application, owner_id: group.id, owner_type: 'Namespace') } + let_it_be(:show_path) { group_settings_application_path(group, application) } + let_it_be(:create_path) { group_settings_applications_path(group) } + + before do + sign_in(user) + group.add_owner(user) + end + + include_examples 'applications controller - GET #show' + + include_examples 'applications controller - POST #create' +end diff --git a/spec/requests/import/gitlab_groups_controller_spec.rb b/spec/requests/import/gitlab_groups_controller_spec.rb index 1f6487986a3..4abf99cf994 100644 --- a/spec/requests/import/gitlab_groups_controller_spec.rb +++ b/spec/requests/import/gitlab_groups_controller_spec.rb @@ -60,6 +60,7 @@ RSpec.describe Import::GitlabGroupsController do end it 'imports the group data', :sidekiq_inline do + allow(GroupImportWorker).to receive(:with_status).and_return(GroupImportWorker) allow(GroupImportWorker).to receive(:perform_async).and_call_original import_request @@ -67,7 +68,6 @@ RSpec.describe Import::GitlabGroupsController do group = Group.find_by(name: 'test-group-import') expect(GroupImportWorker).to have_received(:perform_async).with(user.id, group.id) - expect(group.description).to eq 'A voluptate non sequi temporibus quam at.' expect(group.visibility_level).to eq Gitlab::VisibilityLevel::PRIVATE end diff --git a/spec/requests/jwks_controller_spec.rb b/spec/requests/jwks_controller_spec.rb index 5eda1979027..6dbb5988f58 100644 --- a/spec/requests/jwks_controller_spec.rb +++ b/spec/requests/jwks_controller_spec.rb @@ -3,6 +3,20 @@ require 'spec_helper' RSpec.describe JwksController do + describe 'Endpoints from the parent Doorkeeper::OpenidConnect::DiscoveryController' do + it 'respond successfully' do + [ + "/oauth/discovery/keys", + "/.well-known/openid-configuration", + "/.well-known/webfinger?resource=#{create(:user).email}" + ].each do |endpoint| + get endpoint + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + describe 'GET /-/jwks' do let(:ci_jwt_signing_key) { OpenSSL::PKey::RSA.generate(1024) } let(:ci_jwk) { ci_jwt_signing_key.to_jwk } diff --git a/spec/requests/oauth/applications_controller_spec.rb b/spec/requests/oauth/applications_controller_spec.rb new file mode 100644 index 00000000000..78f0cedb56f --- /dev/null +++ b/spec/requests/oauth/applications_controller_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Oauth::ApplicationsController do + let_it_be(:user) { create(:user) } + let_it_be(:application) { create(:oauth_application, owner: user) } + let_it_be(:show_path) { oauth_application_path(application) } + let_it_be(:create_path) { oauth_applications_path } + + before do + sign_in(user) + end + + include_examples 'applications controller - GET #show' + + include_examples 'applications controller - POST #create' +end diff --git a/spec/requests/projects/google_cloud_controller_spec.rb b/spec/requests/projects/google_cloud_controller_spec.rb index 3b43f0d1dfb..37682152994 100644 --- a/spec/requests/projects/google_cloud_controller_spec.rb +++ b/spec/requests/projects/google_cloud_controller_spec.rb @@ -2,48 +2,106 @@ require 'spec_helper' +# Mock Types +MockGoogleOAuth2Credentials = Struct.new(:app_id, :app_secret) + RSpec.describe Projects::GoogleCloudController do let_it_be(:project) { create(:project, :public) } describe 'GET index' do let_it_be(:url) { "#{project_google_cloud_index_path(project)}" } - let(:subject) { get url } + context 'when a public request is made' do + it 'returns not found' do + get url - context 'when user is authorized' do - let(:user) { project.creator } + expect(response).to have_gitlab_http_status(:not_found) + end + end - before do + context 'when a project.guest makes request' do + let(:user) { create(:user) } + + it 'returns not found' do + project.add_guest(user) sign_in(user) - subject + + get url + + expect(response).to have_gitlab_http_status(:not_found) end + end - it 'renders content' do - expect(response).to be_successful + context 'when project.developer makes request' do + let(:user) { create(:user) } + + it 'returns not found' do + project.add_developer(user) + sign_in(user) + + get url + + expect(response).to have_gitlab_http_status(:not_found) end end - context 'when user is unauthorized' do + context 'when project.maintainer makes request' do let(:user) { create(:user) } - before do - project.add_guest(user) + it 'returns successful' do + project.add_maintainer(user) sign_in(user) - subject + + get url + + expect(response).to be_successful end + end - it 'shows 404' do - expect(response).to have_gitlab_http_status(:not_found) + context 'when project.creator makes request' do + let(:user) { project.creator } + + it 'returns successful' do + sign_in(user) + + get url + + expect(response).to be_successful end end - context 'when no user is present' do - before do - subject + describe 'when authorized user makes request' do + let(:user) { project.creator } + + context 'but gitlab instance is not configured for google oauth2' do + before do + unconfigured_google_oauth2 = MockGoogleOAuth2Credentials.new('', '') + allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for) + .with('google_oauth2') + .and_return(unconfigured_google_oauth2) + end + + it 'returns forbidden' do + sign_in(user) + + get url + + expect(response).to have_gitlab_http_status(:forbidden) + end end - it 'shows 404' do - expect(response).to have_gitlab_http_status(:not_found) + context 'but feature flag is disabled' do + before do + stub_feature_flags(incubation_5mp_google_cloud: false) + end + + it 'returns not found' do + sign_in(user) + + get url + + expect(response).to have_gitlab_http_status(:not_found) + end end end end diff --git a/spec/requests/projects/issues/discussions_spec.rb b/spec/requests/projects/issues/discussions_spec.rb new file mode 100644 index 00000000000..dcdca2d9c27 --- /dev/null +++ b/spec/requests/projects/issues/discussions_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'issue discussions' do + describe 'GET /:namespace/:project/-/issues/:iid/discussions' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:note_author) { create(:user) } + let_it_be(:notes) { create_list(:note, 5, project: project, noteable: issue, author: note_author) } + + before_all do + project.add_maintainer(user) + end + + context 'HTTP caching' do + def get_discussions + get discussions_namespace_project_issue_path(namespace_id: project.namespace, project_id: project, id: issue.iid), headers: { + 'If-None-Match' => @etag + } + + @etag = response.etag + end + + before do + sign_in(user) + + get_discussions + end + + it 'returns 304 without serializing JSON' do + expect(DiscussionSerializer).not_to receive(:new) + + get_discussions + + expect(response).to have_gitlab_http_status(:not_modified) + end + + shared_examples 'cache miss' do + it 'returns 200 and serializes JSON' do + expect(DiscussionSerializer).to receive(:new).and_call_original + + get_discussions + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when user role changes' do + before do + project.add_guest(user) + end + + it_behaves_like 'cache miss' + end + + context 'when emoji is awarded to a note' do + before do + travel_to(1.minute.from_now) { create(:award_emoji, awardable: notes.first) } + end + + it_behaves_like 'cache miss' + end + + context 'when note author name changes' do + before do + note_author.update!(name: 'New name') + end + + it_behaves_like 'cache miss' + end + + context 'when note author status changes' do + before do + Users::SetStatusService.new(note_author, message: "updated status").execute + end + + it_behaves_like 'cache miss' + end + + context 'when note author role changes' do + before do + project.add_developer(note_author) + end + + it_behaves_like 'cache miss' + end + + context 'when note is added' do + before do + create(:note, project: project, noteable: issue) + end + + it_behaves_like 'cache miss' + end + + context 'when note is modified' do + before do + notes.first.update!(note: 'edited text') + end + + it_behaves_like 'cache miss' + end + + context 'when note is deleted' do + before do + notes.first.destroy! + end + + it_behaves_like 'cache miss' + end + end + end +end diff --git a/spec/requests/projects/issues_controller_spec.rb b/spec/requests/projects/issues_controller_spec.rb new file mode 100644 index 00000000000..f44b1f4d502 --- /dev/null +++ b/spec/requests/projects/issues_controller_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::IssuesController do + let_it_be(:issue) { create(:issue) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { issue.project } + let_it_be(:user) { issue.author } + + before do + login_as(user) + end + + describe 'GET #discussions' do + let_it_be(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } + let_it_be(:discussion_reply) { create(:discussion_note_on_issue, noteable: issue, project: issue.project, in_reply_to: discussion) } + let_it_be(:state_event) { create(:resource_state_event, issue: issue) } + let_it_be(:discussion_2) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } + let_it_be(:discussion_3) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } + + context 'pagination' do + def get_discussions(**params) + get discussions_project_issue_path(project, issue, params: params.merge(format: :json)) + end + + it 'returns paginated notes and cursor based on per_page param' do + get_discussions(per_page: 2) + + discussions = Gitlab::Json.parse(response.body) + notes = discussions.flat_map { |d| d['notes'] } + + expect(discussions.count).to eq(2) + expect(notes).to match([ + a_hash_including('id' => discussion.id.to_s), + a_hash_including('id' => discussion_reply.id.to_s), + a_hash_including('type' => 'StateNote') + ]) + + cursor = response.header['X-Next-Page-Cursor'] + expect(cursor).to be_present + + get_discussions(per_page: 1, cursor: cursor) + + discussions = Gitlab::Json.parse(response.body) + notes = discussions.flat_map { |d| d['notes'] } + + expect(discussions.count).to eq(1) + expect(notes).to match([ + a_hash_including('id' => discussion_2.id.to_s) + ]) + end + + context 'when paginated_issue_discussions is disabled' do + before do + stub_feature_flags(paginated_issue_discussions: false) + end + + it 'returns all discussions and ignores per_page param' do + get_discussions(per_page: 2) + + discussions = Gitlab::Json.parse(response.body) + notes = discussions.flat_map { |d| d['notes'] } + + expect(discussions.count).to eq(4) + expect(notes.count).to eq(5) + end + end + end + end +end diff --git a/spec/requests/projects/usage_quotas_spec.rb b/spec/requests/projects/usage_quotas_spec.rb index 04e01da61ef..114e9bd9f1e 100644 --- a/spec/requests/projects/usage_quotas_spec.rb +++ b/spec/requests/projects/usage_quotas_spec.rb @@ -22,40 +22,26 @@ RSpec.describe 'Project Usage Quotas' do end describe 'GET /:namespace/:project/usage_quotas' do - context 'with project_storage_ui feature flag enabled' do - before do - stub_feature_flags(project_storage_ui: true) - end - - it 'renders usage quotas path' do - mock_storage_app_data = { - project_path: project.full_path, - usage_quotas_help_page_path: help_page_path('user/usage_quotas'), - build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'), - packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'), - repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'), - snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'), - wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size') - } - get project_usage_quotas_path(project) - - expect(response).to have_gitlab_http_status(:ok) - expect(response.body).to include(project_usage_quotas_path(project)) - expect(assigns[:storage_app_data]).to eq(mock_storage_app_data) - expect(response.body).to include("Usage of project resources across the #{project.name} project") - end - - context 'renders :not_found for user without permission' do - let(:role) { :developer } - - it_behaves_like 'response with 404 status' - end + it 'renders usage quotas path' do + mock_storage_app_data = { + project_path: project.full_path, + usage_quotas_help_page_path: help_page_path('user/usage_quotas'), + build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'), + packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'), + repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'), + snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'), + wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size') + } + get project_usage_quotas_path(project) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to include(project_usage_quotas_path(project)) + expect(assigns[:storage_app_data]).to eq(mock_storage_app_data) + expect(response.body).to include("Usage of project resources across the #{project.name} project") end - context 'with project_storage_ui feature flag disabled' do - before do - stub_feature_flags(project_storage_ui: false) - end + context 'renders :not_found for user without permission' do + let(:role) { :developer } it_behaves_like 'response with 404 status' end diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb index 35ce942ed7e..ab0c76397e4 100644 --- a/spec/requests/rack_attack_global_spec.rb +++ b/spec/requests/rack_attack_global_spec.rb @@ -517,11 +517,15 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac let(:path) { "/v2/#{group.path}/dependency_proxy/containers/alpine/manifests/latest" } let(:other_path) { "/v2/#{other_group.path}/dependency_proxy/containers/alpine/manifests/latest" } let(:pull_response) { { status: :success, manifest: manifest, from_cache: false } } + let(:head_response) { { status: :success } } before do allow_next_instance_of(DependencyProxy::FindOrCreateManifestService) do |instance| allow(instance).to receive(:execute).and_return(pull_response) end + allow_next_instance_of(DependencyProxy::HeadManifestService) do |instance| + allow(instance).to receive(:execute).and_return(head_response) + end end it_behaves_like 'rate-limited token-authenticated requests' diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index accacd705e7..701a73761fd 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -305,7 +305,7 @@ RSpec.describe UsersController do context 'user with keys' do let!(:gpg_key) { create(:gpg_key, user: user) } - let!(:another_gpg_key) { create(:another_gpg_key, user: user) } + let!(:another_gpg_key) { create(:another_gpg_key, user: user.reload) } shared_examples_for 'renders all verified GPG keys' do it 'renders all verified keys separated with a new line with text/plain content type' do diff --git a/spec/routing/group_routing_spec.rb b/spec/routing/group_routing_spec.rb index f171c2faf5e..5c2ef62683e 100644 --- a/spec/routing/group_routing_spec.rb +++ b/spec/routing/group_routing_spec.rb @@ -85,6 +85,26 @@ RSpec.describe "Groups", "routing" do expect(get('/v2')).to route_to('groups/dependency_proxy_auth#authenticate') end + it 'routes to #upload_manifest' do + expect(post('v2/gitlabhq/dependency_proxy/containers/alpine/manifests/latest/upload')) + .to route_to('groups/dependency_proxy_for_containers#upload_manifest', group_id: 'gitlabhq', image: 'alpine', tag: 'latest') + end + + it 'routes to #upload_blob' do + expect(post('v2/gitlabhq/dependency_proxy/containers/alpine/blobs/abc12345/upload')) + .to route_to('groups/dependency_proxy_for_containers#upload_blob', group_id: 'gitlabhq', image: 'alpine', sha: 'abc12345') + end + + it 'routes to #upload_manifest_authorize' do + expect(post('v2/gitlabhq/dependency_proxy/containers/alpine/manifests/latest/upload/authorize')) + .to route_to('groups/dependency_proxy_for_containers#authorize_upload_manifest', group_id: 'gitlabhq', image: 'alpine', tag: 'latest') + end + + it 'routes to #upload_blob_authorize' do + expect(post('v2/gitlabhq/dependency_proxy/containers/alpine/blobs/abc12345/upload/authorize')) + .to route_to('groups/dependency_proxy_for_containers#authorize_upload_blob', group_id: 'gitlabhq', image: 'alpine', sha: 'abc12345') + end + context 'image name without namespace' do it 'routes to #manifest' do expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/manifests/2.3.6')) diff --git a/spec/routing/openid_connect_spec.rb b/spec/routing/openid_connect_spec.rb index dc9190114fd..4c08a71ae31 100644 --- a/spec/routing/openid_connect_spec.rb +++ b/spec/routing/openid_connect_spec.rb @@ -2,20 +2,20 @@ require 'spec_helper' -# oauth_discovery_keys GET /oauth/discovery/keys(.:format) doorkeeper/openid_connect/discovery#keys -# oauth_discovery_provider GET /.well-known/openid-configuration(.:format) doorkeeper/openid_connect/discovery#provider -# oauth_discovery_webfinger GET /.well-known/webfinger(.:format) doorkeeper/openid_connect/discovery#webfinger +# oauth_discovery_keys GET /oauth/discovery/keys(.:format) jwks#keys +# oauth_discovery_provider GET /.well-known/openid-configuration(.:format) jwks#provider +# oauth_discovery_webfinger GET /.well-known/webfinger(.:format) jwks#webfinger RSpec.describe Doorkeeper::OpenidConnect::DiscoveryController, 'routing' do it "to #provider" do - expect(get('/.well-known/openid-configuration')).to route_to('doorkeeper/openid_connect/discovery#provider') + expect(get('/.well-known/openid-configuration')).to route_to('jwks#provider') end it "to #webfinger" do - expect(get('/.well-known/webfinger')).to route_to('doorkeeper/openid_connect/discovery#webfinger') + expect(get('/.well-known/webfinger')).to route_to('jwks#webfinger') end it "to #keys" do - expect(get('/oauth/discovery/keys')).to route_to('doorkeeper/openid_connect/discovery#keys') + expect(get('/oauth/discovery/keys')).to route_to('jwks#keys') end end diff --git a/spec/rubocop/cop/gitlab/bulk_insert_spec.rb b/spec/rubocop/cop/gitlab/bulk_insert_spec.rb index bbc8f381d01..7cd003d0a70 100644 --- a/spec/rubocop/cop/gitlab/bulk_insert_spec.rb +++ b/spec/rubocop/cop/gitlab/bulk_insert_spec.rb @@ -6,17 +6,17 @@ require_relative '../../../../rubocop/cop/gitlab/bulk_insert' RSpec.describe RuboCop::Cop::Gitlab::BulkInsert do subject(:cop) { described_class.new } - it 'flags the use of Gitlab::Database.main.bulk_insert' do + it 'flags the use of ApplicationRecord.legacy_bulk_insert' do expect_offense(<<~SOURCE) - Gitlab::Database.main.bulk_insert('merge_request_diff_files', rows) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use the `BulkInsertSafe` concern, [...] + ApplicationRecord.legacy_bulk_insert('merge_request_diff_files', rows) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use the `BulkInsertSafe` concern, [...] SOURCE end - it 'flags the use of ::Gitlab::Database.main.bulk_insert' do + it 'flags the use of ::ApplicationRecord.legacy_bulk_insert' do expect_offense(<<~SOURCE) - ::Gitlab::Database.main.bulk_insert('merge_request_diff_files', rows) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use the `BulkInsertSafe` concern, [...] + ::ApplicationRecord.legacy_bulk_insert('merge_request_diff_files', rows) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use the `BulkInsertSafe` concern, [...] SOURCE end end diff --git a/spec/rubocop/cop/gitlab/change_timezone_spec.rb b/spec/rubocop/cop/gitlab/change_timezone_spec.rb index f3c07e44cc7..ff6365aa0f7 100644 --- a/spec/rubocop/cop/gitlab/change_timezone_spec.rb +++ b/spec/rubocop/cop/gitlab/change_timezone_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'fast_spec_helper' -require_relative '../../../../rubocop/cop/gitlab/change_timzone' +require_relative '../../../../rubocop/cop/gitlab/change_timezone' RSpec.describe RuboCop::Cop::Gitlab::ChangeTimezone do subject(:cop) { described_class.new } diff --git a/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb b/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb new file mode 100644 index 00000000000..fb424da90e8 --- /dev/null +++ b/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +require_relative '../../../../rubocop/cop/qa/duplicate_testcase_link' + +RSpec.describe RuboCop::Cop::QA::DuplicateTestcaseLink do + let(:source_file) { 'qa/page.rb' } + + subject(:cop) { described_class.new } + + context 'in a QA file' do + before do + allow(cop).to receive(:in_qa_file?).and_return(true) + end + + it "registers an offense for a duplicate testcase link" do + expect_offense(<<-RUBY) + it 'some test', testcase: '/quality/test_cases/1892' do + end + it 'another test', testcase: '/quality/test_cases/1892' do + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't reuse the same testcase link in different tests. Replace one of `/quality/test_cases/1892`. + end + RUBY + end + + it "doesnt offend if testcase link is unique" do + expect_no_offenses(<<-RUBY) + it 'some test', testcase: '/quality/test_cases/1893' do + end + it 'another test', testcase: '/quality/test_cases/1894' do + end + RUBY + end + end +end diff --git a/spec/scripts/changed-feature-flags_spec.rb b/spec/scripts/changed-feature-flags_spec.rb new file mode 100644 index 00000000000..5c858588c0c --- /dev/null +++ b/spec/scripts/changed-feature-flags_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +load File.expand_path('../../scripts/changed-feature-flags', __dir__) + +RSpec.describe 'scripts/changed-feature-flags' do + describe GetFeatureFlagsFromFiles do + let(:feature_flag_definition1) do + file = Tempfile.new('foo.yml', ff_dir) + file.write(<<~YAML) + --- + name: foo_flag + default_enabled: true + YAML + file.rewind + file + end + + let(:feature_flag_definition2) do + file = Tempfile.new('bar.yml', ff_dir) + file.write(<<~YAML) + --- + name: bar_flag + default_enabled: false + YAML + file.rewind + file + end + + after do + FileUtils.remove_entry(ff_dir, true) + end + + describe '.extracted_flags' do + shared_examples 'extract feature flags' do + it 'returns feature flags on their own' do + subject = described_class.new({ files: [feature_flag_definition1.path, feature_flag_definition2.path] }) + + expect(subject.extracted_flags).to eq('foo_flag,bar_flag') + end + + it 'returns feature flags and their state as enabled' do + subject = described_class.new({ files: [feature_flag_definition1.path, feature_flag_definition2.path], state: 'enabled' }) + + expect(subject.extracted_flags).to eq('foo_flag=enabled,bar_flag=enabled') + end + + it 'returns feature flags and their state as disabled' do + subject = described_class.new({ files: [feature_flag_definition1.path, feature_flag_definition2.path], state: 'disabled' }) + + expect(subject.extracted_flags).to eq('foo_flag=disabled,bar_flag=disabled') + end + end + + context 'with definition files in the development directory' do + let(:ff_dir) { FileUtils.mkdir_p(File.join(Dir.tmpdir, 'feature_flags', 'development')) } + + it_behaves_like 'extract feature flags' + end + + context 'with definition files in the ops directory' do + let(:ff_dir) { FileUtils.mkdir_p(File.join(Dir.tmpdir, 'feature_flags', 'ops')) } + + it_behaves_like 'extract feature flags' + end + + context 'with definition files in the experiment directory' do + let(:ff_dir) { FileUtils.mkdir_p(File.join(Dir.tmpdir, 'feature_flags', 'experiment')) } + + it 'ignores the files' do + subject = described_class.new({ files: [feature_flag_definition1.path, feature_flag_definition2.path] }) + + expect(subject.extracted_flags).to eq('') + end + end + end + end +end diff --git a/spec/scripts/failed_tests_spec.rb b/spec/scripts/failed_tests_spec.rb new file mode 100644 index 00000000000..92eae75b3be --- /dev/null +++ b/spec/scripts/failed_tests_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../scripts/failed_tests' + +RSpec.describe FailedTests do + let(:report_file) { 'spec/fixtures/scripts/test_report.json' } + let(:output_directory) { 'tmp/previous_test_results' } + let(:rspec_pg_regex) { /rspec .+ pg12( .+)?/ } + let(:rspec_ee_pg_regex) { /rspec-ee .+ pg12( .+)?/ } + + subject { described_class.new(previous_tests_report_path: report_file, output_directory: output_directory, rspec_pg_regex: rspec_pg_regex, rspec_ee_pg_regex: rspec_ee_pg_regex) } + + describe '#output_failed_test_files' do + it 'writes the file for the suite' do + expect(File).to receive(:open).with(File.join(output_directory, "rspec_failed_files.txt"), 'w').once + + subject.output_failed_test_files + end + end + + describe '#failed_files_for_suite_collection' do + let(:failure_path) { 'path/to/fail_file_spec.rb' } + let(:other_failure_path) { 'path/to/fail_file_spec_2.rb' } + let(:file_contents_as_json) do + { + 'suites' => [ + { + 'failed_count' => 1, + 'name' => 'rspec unit pg12 10/12', + 'test_cases' => [ + { + 'status' => 'failed', + 'file' => failure_path + } + ] + }, + { + 'failed_count' => 1, + 'name' => 'rspec-ee unit pg12', + 'test_cases' => [ + { + 'status' => 'failed', + 'file' => failure_path + } + ] + }, + { + 'failed_count' => 1, + 'name' => 'rspec unit pg13 10/12', + 'test_cases' => [ + { + 'status' => 'failed', + 'file' => other_failure_path + } + ] + } + ] + } + end + + before do + allow(subject).to receive(:file_contents_as_json).and_return(file_contents_as_json) + end + + it 'returns a list of failed file paths for suite collection' do + result = subject.failed_files_for_suite_collection + + expect(result[:rspec].to_a).to match_array(failure_path) + expect(result[:rspec_ee].to_a).to match_array(failure_path) + end + end + + describe 'empty report' do + let(:file_content) do + '{}' + end + + before do + allow(subject).to receive(:file_contents).and_return(file_content) + end + + it 'does not fail for output files' do + subject.output_failed_test_files + end + + it 'returns empty results for suite failures' do + result = subject.failed_files_for_suite_collection + + expect(result.values.flatten).to be_empty + end + end + + describe 'invalid report' do + let(:file_content) do + '' + end + + before do + allow(subject).to receive(:file_contents).and_return(file_content) + end + + it 'does not fail for output files' do + subject.output_failed_test_files + end + + it 'returns empty results for suite failures' do + result = subject.failed_files_for_suite_collection + + expect(result.values.flatten).to be_empty + end + end + + describe 'missing report file' do + let(:report_file) { 'unknownfile.json' } + + it 'does not fail for output files' do + subject.output_failed_test_files + end + + it 'returns empty results for suite failures' do + result = subject.failed_files_for_suite_collection + + expect(result.values.flatten).to be_empty + end + end +end diff --git a/spec/scripts/pipeline_test_report_builder_spec.rb b/spec/scripts/pipeline_test_report_builder_spec.rb new file mode 100644 index 00000000000..8553ada044e --- /dev/null +++ b/spec/scripts/pipeline_test_report_builder_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../scripts/pipeline_test_report_builder' + +RSpec.describe PipelineTestReportBuilder do + let(:report_file) { 'spec/fixtures/scripts/test_report.json' } + let(:output_file_path) { 'tmp/previous_test_results/output_file.json' } + + subject do + described_class.new( + target_project: 'gitlab-org/gitlab', + mr_id: '999', + instance_base_url: 'https://gitlab.com', + output_file_path: output_file_path + ) + end + + let(:failed_pipeline_url) { 'pipeline2_url' } + + let(:failed_pipeline) do + { + 'status' => 'failed', + 'created_at' => (DateTime.now - 5).to_s, + 'web_url' => failed_pipeline_url + } + end + + let(:current_pipeline) do + { + 'status' => 'running', + 'created_at' => DateTime.now.to_s, + 'web_url' => 'pipeline1_url' + } + end + + let(:mr_pipelines) { [current_pipeline, failed_pipeline] } + + let(:failed_build_id) { 9999 } + + let(:failed_builds_for_pipeline) do + [ + { + 'id' => failed_build_id, + 'stage' => 'test' + } + ] + end + + let(:test_report_for_build) do + { + "name": "rspec-ee system pg11 geo", + "failed_count": 41, + "test_cases": [ + { + "status": "failed", + "name": "example", + "classname": "ee.spec.features.geo_node_spec", + "file": "./ee/spec/features/geo_node_spec.rb", + "execution_time": 6.324748, + "system_output": { + "__content__": "\n", + "message": "RSpec::Core::MultipleExceptionError", + "type": "RSpec::Core::MultipleExceptionError" + } + } + ] + } + end + + before do + allow(subject).to receive(:pipelines_for_mr).and_return(mr_pipelines) + allow(subject).to receive(:failed_builds_for_pipeline).and_return(failed_builds_for_pipeline) + end + + describe '#previous_pipeline' do + let(:fork_pipeline_url) { 'fork_pipeline_url' } + let(:fork_pipeline) do + { + 'status' => 'failed', + 'created_at' => (DateTime.now - 5).to_s, + 'web_url' => fork_pipeline_url + } + end + + before do + allow(subject).to receive(:test_report_for_build).and_return(test_report_for_build) + end + + context 'pipeline in a fork project' do + let(:mr_pipelines) { [current_pipeline, fork_pipeline] } + + it 'returns fork pipeline' do + expect(subject.previous_pipeline).to eq(fork_pipeline) + end + end + + context 'pipeline in target project' do + it 'returns failed pipeline' do + expect(subject.previous_pipeline).to eq(failed_pipeline) + end + end + end + + describe '#test_report_for_latest_pipeline' do + it 'fetches builds from pipeline related to MR' do + expect(subject).to receive(:fetch).with("#{failed_pipeline_url}/tests/suite.json?build_ids[]=#{failed_build_id}").and_return(failed_builds_for_pipeline) + subject.test_report_for_latest_pipeline + end + + context 'canonical pipeline' do + before do + allow(subject).to receive(:test_report_for_build).and_return(test_report_for_build) + end + + context 'no previous pipeline' do + let(:mr_pipelines) { [] } + + it 'returns empty hash' do + expect(subject.test_report_for_latest_pipeline).to eq("{}") + end + end + + context 'first pipeline scenario' do + let(:mr_pipelines) do + [ + { + 'status' => 'running', + 'created_at' => DateTime.now.to_s + } + ] + end + + it 'returns empty hash' do + expect(subject.test_report_for_latest_pipeline).to eq("{}") + end + end + + context 'no previous failed pipeline' do + let(:mr_pipelines) do + [ + { + 'status' => 'running', + 'created_at' => DateTime.now.to_s + }, + { + 'status' => 'success', + 'created_at' => (DateTime.now - 5).to_s + } + ] + end + + it 'returns empty hash' do + expect(subject.test_report_for_latest_pipeline).to eq("{}") + end + end + + context 'no failed test builds' do + let(:failed_builds_for_pipeline) do + [ + { + 'id' => 9999, + 'stage' => 'prepare' + } + ] + end + + it 'returns empty hash' do + expect(subject.test_report_for_latest_pipeline).to eq("{}") + end + end + + context 'failed pipeline and failed test builds' do + it 'returns populated test list for suites' do + actual = subject.test_report_for_latest_pipeline + expected = { + 'suites' => [test_report_for_build] + }.to_json + + expect(actual).to eq(expected) + end + end + end + end +end diff --git a/spec/serializers/analytics_summary_serializer_spec.rb b/spec/serializers/analytics_summary_serializer_spec.rb index 9429c9d571a..6563b58c334 100644 --- a/spec/serializers/analytics_summary_serializer_spec.rb +++ b/spec/serializers/analytics_summary_serializer_spec.rb @@ -36,7 +36,7 @@ RSpec.describe AnalyticsSummarySerializer do context 'when representing with unit' do let(:resource) do Gitlab::CycleAnalytics::Summary::DeploymentFrequency - .new(deployments: 10, options: { from: 1.day.ago }) + .new(deployments: 10, options: { from: 1.day.ago }, project: project) end subject { described_class.new.represent(resource, with_unit: true) } diff --git a/spec/serializers/merge_request_user_entity_spec.rb b/spec/serializers/merge_request_user_entity_spec.rb index 026a229322e..72d1b0c0dd2 100644 --- a/spec/serializers/merge_request_user_entity_spec.rb +++ b/spec/serializers/merge_request_user_entity_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe MergeRequestUserEntity do let_it_be(:user) { create(:user) } - let_it_be(:merge_request) { create(:merge_request) } + let_it_be(:merge_request) { create(:merge_request, assignees: [user]) } let(:request) { EntityRequest.new(project: merge_request.target_project, current_user: user) } @@ -18,7 +18,8 @@ RSpec.describe MergeRequestUserEntity do it 'exposes needed attributes' do is_expected.to include( :id, :name, :username, :state, :avatar_url, :web_url, - :can_merge, :can_update_merge_request, :reviewed, :approved + :can_merge, :can_update_merge_request, :reviewed, :approved, + :attention_requested ) end @@ -56,6 +57,10 @@ RSpec.describe MergeRequestUserEntity do end end + context 'attention_requested' do + it { is_expected.to include(attention_requested: true ) } + end + describe 'performance' do let_it_be(:user_a) { create(:user) } let_it_be(:user_b) { create(:user) } diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb index fcfdbfc0967..3e0c61a26c0 100644 --- a/spec/serializers/merge_request_widget_entity_spec.rb +++ b/spec/serializers/merge_request_widget_entity_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe MergeRequestWidgetEntity do include ProjectForksHelper + include Gitlab::Routing.url_helpers let(:project) { create :project, :repository } let(:resource) { create(:merge_request, source_project: project, target_project: project) } @@ -140,17 +141,15 @@ RSpec.describe MergeRequestWidgetEntity do let(:role) { :developer } it 'has add ci config path' do - expected_path = "/#{resource.project.full_path}/-/new/#{resource.source_branch}" + expected_path = project_ci_pipeline_editor_path(project) expect(subject[:merge_request_add_ci_config_path]).to include(expected_path) end it 'has expected params' do expected_params = { - commit_message: 'Add .gitlab-ci.yml', - file_name: '.gitlab-ci.yml', - suggest_gitlab_ci_yml: 'true', - mr_path: "/#{resource.project.full_path}/-/merge_requests/#{resource.iid}" + branch_name: resource.source_branch, + add_new_config_file: 'true' }.with_indifferent_access uri = Addressable::URI.parse(subject[:merge_request_add_ci_config_path]) @@ -188,30 +187,6 @@ RSpec.describe MergeRequestWidgetEntity do end end - context 'when ci_config_path is customized' do - it 'has no path if ci_config_path is not set to our default setting' do - project.ci_config_path = 'not_default' - - expect(subject[:merge_request_add_ci_config_path]).to be_nil - end - - it 'has a path if ci_config_path unset' do - expect(subject[:merge_request_add_ci_config_path]).not_to be_nil - end - - it 'has a path if ci_config_path is an empty string' do - project.ci_config_path = '' - - expect(subject[:merge_request_add_ci_config_path]).not_to be_nil - end - - it 'has a path if ci_config_path is set to our default file' do - project.ci_config_path = Gitlab::FileDetector::PATTERNS[:gitlab_ci] - - expect(subject[:merge_request_add_ci_config_path]).not_to be_nil - end - end - context 'when build feature is disabled' do before do project.project_feature.update!(builds_access_level: ProjectFeature::DISABLED) diff --git a/spec/serializers/service_field_entity_spec.rb b/spec/serializers/service_field_entity_spec.rb index 6e9ebfb66d9..a06fdf95159 100644 --- a/spec/serializers/service_field_entity_spec.rb +++ b/spec/serializers/service_field_entity_spec.rb @@ -27,7 +27,8 @@ RSpec.describe ServiceFieldEntity do help: 'Use a username for server version and an email for cloud version.', required: true, choices: nil, - value: 'jira_username' + value: 'jira_username', + checkbox_label: nil } is_expected.to eq(expected_hash) @@ -46,7 +47,8 @@ RSpec.describe ServiceFieldEntity do help: 'Leave blank to use your current password or API token.', required: true, choices: nil, - value: 'true' + value: 'true', + checkbox_label: nil } is_expected.to eq(expected_hash) @@ -68,7 +70,8 @@ RSpec.describe ServiceFieldEntity do placeholder: nil, required: nil, choices: nil, - value: 'true' + value: 'true', + checkbox_label: nil } is_expected.to include(expected_hash) @@ -83,12 +86,13 @@ RSpec.describe ServiceFieldEntity do expected_hash = { type: 'select', name: 'branches_to_be_notified', - title: nil, + title: 'Branches for which notifications are to be sent', placeholder: nil, required: nil, choices: [['All branches', 'all'], ['Default branch', 'default'], ['Protected branches', 'protected'], ['Default branch and protected branches', 'default_and_protected']], help: nil, - value: nil + value: nil, + checkbox_label: nil } is_expected.to eq(expected_hash) diff --git a/spec/services/admin/propagate_integration_service_spec.rb b/spec/services/admin/propagate_integration_service_spec.rb index 151658fe429..b379286ba4f 100644 --- a/spec/services/admin/propagate_integration_service_spec.rb +++ b/spec/services/admin/propagate_integration_service_spec.rb @@ -55,7 +55,7 @@ RSpec.describe Admin::PropagateIntegrationService do end context 'for a group-level integration' do - let(:group_integration) { create(:jira_integration, group: group, project: nil) } + let(:group_integration) { create(:jira_integration, :group, group: group) } context 'with a project without integration' do let(:another_project) { create(:project, group: group) } @@ -81,7 +81,7 @@ RSpec.describe Admin::PropagateIntegrationService do context 'with a subgroup with integration' do let(:subgroup) { create(:group, parent: group) } - let(:subgroup_integration) { create(:jira_integration, group: subgroup, project: nil, inherit_from_id: group_integration.id) } + let(:subgroup_integration) { create(:jira_integration, :group, group: subgroup, inherit_from_id: group_integration.id) } it 'calls to PropagateIntegrationInheritDescendantWorker' do expect(PropagateIntegrationInheritDescendantWorker).to receive(:perform_async) diff --git a/spec/services/authorized_project_update/project_access_changed_service_spec.rb b/spec/services/authorized_project_update/project_access_changed_service_spec.rb new file mode 100644 index 00000000000..11621055a47 --- /dev/null +++ b/spec/services/authorized_project_update/project_access_changed_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AuthorizedProjectUpdate::ProjectAccessChangedService do + describe '#execute' do + it 'schedules the project IDs' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to receive(:bulk_perform_and_wait) + .with([[1], [2]]) + + described_class.new([1, 2]).execute + end + + it 'permits non-blocking operation' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to receive(:bulk_perform_async) + .with([[1], [2]]) + + described_class.new([1, 2]).execute(blocking: false) + end + end +end diff --git a/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb index eaa5f723bec..6f28f892f00 100644 --- a/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb +++ b/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb @@ -24,6 +24,10 @@ RSpec.describe AutoMerge::MergeWhenPipelineSucceedsService do project.add_maintainer(user) end + before do + allow(MergeWorker).to receive(:with_status).and_return(MergeWorker) + end + describe "#available_for?" do subject { service.available_for?(mr_merge_if_green_enabled) } diff --git a/spec/services/award_emojis/base_service_spec.rb b/spec/services/award_emojis/base_service_spec.rb new file mode 100644 index 00000000000..e0c8fd39ad9 --- /dev/null +++ b/spec/services/award_emojis/base_service_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AwardEmojis::BaseService do + let(:awardable) { build(:note) } + let(:current_user) { build(:user) } + + describe '.initialize' do + subject { described_class } + + it 'uses same emoji name if not an alias' do + emoji_name = 'horse' + + expect(subject.new(awardable, emoji_name, current_user).name).to eq(emoji_name) + end + + it 'uses emoji original name if its an alias' do + emoji_alias = 'small_airplane' + emoji_name = 'airplane_small' + + expect(subject.new(awardable, emoji_alias, current_user).name).to eq(emoji_name) + end + end +end diff --git a/spec/services/bulk_create_integration_service_spec.rb b/spec/services/bulk_create_integration_service_spec.rb index 517222c0e69..63bdc39857c 100644 --- a/spec/services/bulk_create_integration_service_spec.rb +++ b/spec/services/bulk_create_integration_service_spec.rb @@ -25,7 +25,7 @@ RSpec.describe BulkCreateIntegrationService do end context 'integration with data fields' do - let(:excluded_attributes) { %w[id service_id created_at updated_at] } + let(:excluded_attributes) { %w[id service_id integration_id created_at updated_at] } it 'updates the data fields from inherited integrations' do described_class.new(integration, batch, association).execute @@ -74,7 +74,7 @@ RSpec.describe BulkCreateIntegrationService do context 'with a project association' do let!(:project) { create(:project, group: group) } - let(:integration) { create(:jira_integration, group: group, project: nil) } + let(:integration) { create(:jira_integration, :group, group: group) } let(:created_integration) { project.jira_integration } let(:batch) { Project.where(id: Project.minimum(:id)..Project.maximum(:id)).without_integration(integration).in_namespace(integration.group.self_and_descendants) } let(:association) { 'project' } @@ -82,11 +82,19 @@ RSpec.describe BulkCreateIntegrationService do it_behaves_like 'creates integration from batch ids' it_behaves_like 'updates inherit_from_id' + + context 'with different foreign key of data_fields' do + let(:integration) { create(:zentao_integration, :group, group: group) } + let(:created_integration) { project.zentao_integration } + + it_behaves_like 'creates integration from batch ids' + it_behaves_like 'updates inherit_from_id' + end end context 'with a group association' do let!(:subgroup) { create(:group, parent: group) } - let(:integration) { create(:jira_integration, group: group, project: nil, inherit_from_id: instance_integration.id) } + let(:integration) { create(:jira_integration, :group, group: group, inherit_from_id: instance_integration.id) } let(:created_integration) { Integration.find_by(group: subgroup) } let(:batch) { Group.where(id: subgroup.id) } let(:association) { 'group' } @@ -94,6 +102,13 @@ RSpec.describe BulkCreateIntegrationService do it_behaves_like 'creates integration from batch ids' it_behaves_like 'updates inherit_from_id' + + context 'with different foreign key of data_fields' do + let(:integration) { create(:zentao_integration, :group, group: group, inherit_from_id: instance_integration.id) } + + it_behaves_like 'creates integration from batch ids' + it_behaves_like 'updates inherit_from_id' + end end end end diff --git a/spec/services/bulk_update_integration_service_spec.rb b/spec/services/bulk_update_integration_service_spec.rb index c10a9b75648..5e521b98482 100644 --- a/spec/services/bulk_update_integration_service_spec.rb +++ b/spec/services/bulk_update_integration_service_spec.rb @@ -16,32 +16,19 @@ RSpec.describe BulkUpdateIntegrationService do let_it_be(:group) { create(:group) } let_it_be(:subgroup) { create(:group, parent: group) } - let_it_be(:group_integration) do - Integrations::Jira.create!( - group: group, - url: 'http://group.jira.com' - ) - end - + let_it_be(:group_integration) { create(:jira_integration, :group, group: group, url: 'http://group.jira.com') } + let_it_be(:excluded_integration) { create(:jira_integration, :group, group: create(:group), url: 'http://another.jira.com', push_events: false) } let_it_be(:subgroup_integration) do - Integrations::Jira.create!( - inherit_from_id: group_integration.id, + create(:jira_integration, :group, group: subgroup, + inherit_from_id: group_integration.id, url: 'http://subgroup.jira.com', push_events: true ) end - let_it_be(:excluded_integration) do - Integrations::Jira.create!( - group: create(:group), - url: 'http://another.jira.com', - push_events: false - ) - end - let_it_be(:integration) do - Integrations::Jira.create!( + create(:jira_integration, project: create(:project, group: subgroup), inherit_from_id: subgroup_integration.id, url: 'http://project.jira.com', @@ -88,4 +75,22 @@ RSpec.describe BulkUpdateIntegrationService do described_class.new(group_integration, [integration]).execute end.to change { integration.reload.url }.to(group_integration.url) end + + context 'with different foreign key of data_fields' do + let(:integration) { create(:zentao_integration, project: create(:project, group: group)) } + let(:group_integration) do + create(:zentao_integration, :group, + group: group, + url: 'https://group.zentao.net', + api_token: 'GROUP_TOKEN', + zentao_product_xid: '1' + ) + end + + it 'works with batch as an array of ActiveRecord objects' do + expect do + described_class.new(group_integration, [integration]).execute + end.to change { integration.reload.url }.to(group_integration.url) + end + end end diff --git a/spec/services/ci/create_pipeline_service/include_spec.rb b/spec/services/ci/create_pipeline_service/include_spec.rb index 5e7dace8e15..aa01977272a 100644 --- a/spec/services/ci/create_pipeline_service/include_spec.rb +++ b/spec/services/ci/create_pipeline_service/include_spec.rb @@ -7,9 +7,11 @@ RSpec.describe Ci::CreatePipelineService do let_it_be(:project) { create(:project, :repository) } let_it_be(:user) { project.owner } - let(:ref) { 'refs/heads/master' } - let(:source) { :push } - let(:service) { described_class.new(project, user, { ref: ref }) } + let(:ref) { 'refs/heads/master' } + let(:variables_attributes) { [{ key: 'MYVAR', secret_value: 'hello' }] } + let(:source) { :push } + + let(:service) { described_class.new(project, user, { ref: ref, variables_attributes: variables_attributes }) } let(:pipeline) { service.execute(source).payload } let(:file_location) { 'spec/fixtures/gitlab/ci/external_files/.gitlab-ci-template-1.yml' } @@ -24,6 +26,20 @@ RSpec.describe Ci::CreatePipelineService do .and_return(File.read(Rails.root.join(file_location))) end + shared_examples 'not including the file' do + it 'does not include the job in the file' do + expect(pipeline).to be_created_successfully + expect(pipeline.processables.pluck(:name)).to contain_exactly('job') + end + end + + shared_examples 'including the file' do + it 'includes the job in the file' do + expect(pipeline).to be_created_successfully + expect(pipeline.processables.pluck(:name)).to contain_exactly('job', 'rspec') + end + end + context 'with a local file' do let(:config) do <<~EOY @@ -33,13 +49,10 @@ RSpec.describe Ci::CreatePipelineService do EOY end - it 'includes the job in the file' do - expect(pipeline).to be_created_successfully - expect(pipeline.processables.pluck(:name)).to contain_exactly('job', 'rspec') - end + it_behaves_like 'including the file' end - context 'with a local file with rules' do + context 'with a local file with rules with a project variable' do let(:config) do <<~EOY include: @@ -54,19 +67,63 @@ RSpec.describe Ci::CreatePipelineService do context 'when the rules matches' do let(:project_id) { project.id } - it 'includes the job in the file' do - expect(pipeline).to be_created_successfully - expect(pipeline.processables.pluck(:name)).to contain_exactly('job', 'rspec') - end + it_behaves_like 'including the file' end context 'when the rules does not match' do let(:project_id) { non_existing_record_id } - it 'does not include the job in the file' do - expect(pipeline).to be_created_successfully - expect(pipeline.processables.pluck(:name)).to contain_exactly('job') - end + it_behaves_like 'not including the file' + end + end + + context 'with a local file with rules with a predefined pipeline variable' do + let(:config) do + <<~EOY + include: + - local: #{file_location} + rules: + - if: $CI_PIPELINE_SOURCE == "#{pipeline_source}" + job: + script: exit 0 + EOY + end + + context 'when the rules matches' do + let(:pipeline_source) { 'push' } + + it_behaves_like 'including the file' + end + + context 'when the rules does not match' do + let(:pipeline_source) { 'web' } + + it_behaves_like 'not including the file' + end + end + + context 'with a local file with rules with a run pipeline variable' do + let(:config) do + <<~EOY + include: + - local: #{file_location} + rules: + - if: $MYVAR == "#{my_var}" + job: + script: exit 0 + EOY + end + + context 'when the rules matches' do + let(:my_var) { 'hello' } + + it_behaves_like 'including the file' + end + + context 'when the rules does not match' do + let(:my_var) { 'mello' } + + it_behaves_like 'not including the file' end end end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 78646665539..c78e19ea62d 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do include ProjectForksHelper - let_it_be(:project, reload: true) { create(:project, :repository) } - let_it_be(:user, reload: true) { project.owner } + let_it_be_with_refind(:project) { create(:project, :repository) } + let_it_be_with_reload(:user) { project.owner } let(:ref_name) { 'refs/heads/master' } diff --git a/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb b/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb index 04d75630295..d5881d3b204 100644 --- a/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb +++ b/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb @@ -26,28 +26,6 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do pull_request.update!(source_branch: source_branch.name, source_sha: source_branch.target) end - context 'when the FF ci_create_external_pr_pipeline_async is disabled' do - before do - stub_feature_flags(ci_create_external_pr_pipeline_async: false) - end - - it 'creates a pipeline for external pull request', :aggregate_failures do - pipeline = execute.payload - - expect(execute).to be_success - expect(pipeline).to be_valid - expect(pipeline).to be_persisted - expect(pipeline).to be_external_pull_request_event - expect(pipeline).to eq(project.ci_pipelines.last) - expect(pipeline.external_pull_request).to eq(pull_request) - expect(pipeline.user).to eq(user) - expect(pipeline.status).to eq('created') - expect(pipeline.ref).to eq(pull_request.source_branch) - expect(pipeline.sha).to eq(pull_request.source_sha) - expect(pipeline.source_sha).to eq(pull_request.source_sha) - end - end - it 'enqueues Ci::ExternalPullRequests::CreatePipelineWorker' do expect { execute } .to change { ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.count } diff --git a/spec/services/ci/generate_kubeconfig_service_spec.rb b/spec/services/ci/generate_kubeconfig_service_spec.rb new file mode 100644 index 00000000000..b0673d16158 --- /dev/null +++ b/spec/services/ci/generate_kubeconfig_service_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::GenerateKubeconfigService do + describe '#execute' do + let(:project) { create(:project) } + let(:build) { create(:ci_build, project: project) } + let(:agent1) { create(:cluster_agent, project: project) } + let(:agent2) { create(:cluster_agent) } + + let(:template) { instance_double(Gitlab::Kubernetes::Kubeconfig::Template) } + + subject { described_class.new(build).execute } + + before do + expect(Gitlab::Kubernetes::Kubeconfig::Template).to receive(:new).and_return(template) + expect(build.pipeline).to receive(:authorized_cluster_agents).and_return([agent1, agent2]) + end + + it 'adds a cluster, and a user and context for each available agent' do + expect(template).to receive(:add_cluster).with( + name: 'gitlab', + url: Gitlab::Kas.tunnel_url + ).once + + expect(template).to receive(:add_user).with( + name: "agent:#{agent1.id}", + token: "ci:#{agent1.id}:#{build.token}" + ) + expect(template).to receive(:add_user).with( + name: "agent:#{agent2.id}", + token: "ci:#{agent2.id}:#{build.token}" + ) + + expect(template).to receive(:add_context).with( + name: "#{project.full_path}:#{agent1.name}", + cluster: 'gitlab', + user: "agent:#{agent1.id}" + ) + expect(template).to receive(:add_context).with( + name: "#{agent2.project.full_path}:#{agent2.name}", + cluster: 'gitlab', + user: "agent:#{agent2.id}" + ) + + expect(subject).to eq(template) + end + end +end diff --git a/spec/services/ci/job_artifacts/create_service_spec.rb b/spec/services/ci/job_artifacts/create_service_spec.rb index e6d9f208096..6ad3e9ceb54 100644 --- a/spec/services/ci/job_artifacts/create_service_spec.rb +++ b/spec/services/ci/job_artifacts/create_service_spec.rb @@ -49,6 +49,7 @@ RSpec.describe Ci::JobArtifacts::CreateService do expect(new_artifact.file_type).to eq(params['artifact_type']) expect(new_artifact.file_format).to eq(params['artifact_format']) expect(new_artifact.file_sha256).to eq(artifacts_sha256) + expect(new_artifact.locked).to eq(job.pipeline.locked) end it 'does not track the job user_id' do @@ -75,6 +76,7 @@ RSpec.describe Ci::JobArtifacts::CreateService do expect(new_artifact.file_type).to eq('metadata') expect(new_artifact.file_format).to eq('gzip') expect(new_artifact.file_sha256).to eq(artifacts_sha256) + expect(new_artifact.locked).to eq(job.pipeline.locked) end it 'sets expiration date according to application settings' do @@ -175,18 +177,6 @@ RSpec.describe Ci::JobArtifacts::CreateService do hash_including('key' => 'KEY1', 'value' => 'VAR1', 'source' => 'dotenv'), hash_including('key' => 'KEY2', 'value' => 'VAR2', 'source' => 'dotenv')) end - - context 'when ci_synchronous_artifact_parsing feature flag is disabled' do - before do - stub_feature_flags(ci_synchronous_artifact_parsing: false) - end - - it 'does not call parse service' do - expect(Ci::ParseDotenvArtifactService).not_to receive(:new) - - expect(subject[:status]).to eq(:success) - end - end end context 'when artifact_type is metrics' do diff --git a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb index 7a91ad9dcc1..6761f052e18 100644 --- a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb +++ b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb @@ -16,26 +16,43 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s let_it_be(:job) { create(:ci_build, :success, pipeline: pipeline) } context 'when artifact is expired' do - let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } + let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) } context 'with preloaded relationships' do before do stub_const("#{described_class}::LOOP_LIMIT", 1) end - it 'performs the smallest number of queries for job_artifacts' do - log = ActiveRecord::QueryRecorder.new { subject } + context 'with ci_destroy_unlocked_job_artifacts feature flag disabled' do + before do + stub_feature_flags(ci_destroy_unlocked_job_artifacts: false) + end + + it 'performs the smallest number of queries for job_artifacts' do + log = ActiveRecord::QueryRecorder.new { subject } + + # SELECT expired ci_job_artifacts - 3 queries from each_batch + # PRELOAD projects, routes, project_statistics + # BEGIN + # INSERT into ci_deleted_objects + # DELETE loaded ci_job_artifacts + # DELETE security_findings -- for EE + # COMMIT + # SELECT next expired ci_job_artifacts + + expect(log.count).to be_within(1).of(10) + end + end - # SELECT expired ci_job_artifacts - 3 queries from each_batch - # PRELOAD projects, routes, project_statistics - # BEGIN - # INSERT into ci_deleted_objects - # DELETE loaded ci_job_artifacts - # DELETE security_findings -- for EE - # COMMIT - # SELECT next expired ci_job_artifacts + context 'with ci_destroy_unlocked_job_artifacts feature flag enabled' do + before do + stub_feature_flags(ci_destroy_unlocked_job_artifacts: true) + end - expect(log.count).to be_within(1).of(10) + it 'performs the smallest number of queries for job_artifacts' do + log = ActiveRecord::QueryRecorder.new { subject } + expect(log.count).to be_within(1).of(8) + end end end @@ -53,7 +70,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when the artifact has a file attached to it' do - let!(:artifact) { create(:ci_job_artifact, :expired, :zip, job: job) } + let!(:artifact) { create(:ci_job_artifact, :expired, :zip, job: job, locked: job.pipeline.locked) } it 'creates a deleted object' do expect { subject }.to change { Ci::DeletedObject.count }.by(1) @@ -74,7 +91,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when artifact is locked' do - let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job) } + let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job, locked: locked_job.pipeline.locked) } it 'does not destroy job artifact' do expect { subject }.not_to change { Ci::JobArtifact.count } @@ -83,7 +100,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when artifact is not expired' do - let!(:artifact) { create(:ci_job_artifact, job: job) } + let!(:artifact) { create(:ci_job_artifact, job: job, locked: job.pipeline.locked) } it 'does not destroy expired job artifacts' do expect { subject }.not_to change { Ci::JobArtifact.count } @@ -91,7 +108,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when artifact is permanent' do - let!(:artifact) { create(:ci_job_artifact, expire_at: nil, job: job) } + let!(:artifact) { create(:ci_job_artifact, expire_at: nil, job: job, locked: job.pipeline.locked) } it 'does not destroy expired job artifacts' do expect { subject }.not_to change { Ci::JobArtifact.count } @@ -99,7 +116,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when failed to destroy artifact' do - let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } + let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) } before do stub_const("#{described_class}::LOOP_LIMIT", 10) @@ -135,7 +152,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when exclusive lease has already been taken by the other instance' do - let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } + let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) } before do stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY, timeout: described_class::LOCK_TIMEOUT) @@ -149,8 +166,8 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s context 'with a second artifact and batch size of 1' do let(:second_job) { create(:ci_build, :success, pipeline: pipeline) } - let!(:second_artifact) { create(:ci_job_artifact, :archive, expire_at: 1.day.ago, job: second_job) } - let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } + let!(:second_artifact) { create(:ci_job_artifact, :archive, expire_at: 1.day.ago, job: second_job, locked: job.pipeline.locked) } + let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) } before do stub_const("#{described_class}::BATCH_SIZE", 1) @@ -206,8 +223,8 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when some artifacts are locked' do - let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } - let!(:locked_artifact) { create(:ci_job_artifact, :expired, job: locked_job) } + let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) } + let!(:locked_artifact) { create(:ci_job_artifact, :expired, job: locked_job, locked: locked_job.pipeline.locked) } it 'destroys only unlocked artifacts' do expect { subject }.to change { Ci::JobArtifact.count }.by(-1) @@ -216,7 +233,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when all artifacts are locked' do - let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job) } + let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job, locked: locked_job.pipeline.locked) } it 'destroys no artifacts' do expect { subject }.to change { Ci::JobArtifact.count }.by(0) diff --git a/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb b/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb index 2cedbf93d74..1cc856734fc 100644 --- a/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb +++ b/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb @@ -29,7 +29,8 @@ RSpec.describe Ci::JobArtifacts::DestroyBatchService do it 'reports metrics for destroyed artifacts' do expect_next_instance_of(Gitlab::Ci::Artifacts::Metrics) do |metrics| - expect(metrics).to receive(:increment_destroyed_artifacts).with(1).and_call_original + expect(metrics).to receive(:increment_destroyed_artifacts_count).with(1).and_call_original + expect(metrics).to receive(:increment_destroyed_artifacts_bytes).with(107464).and_call_original end execute diff --git a/spec/services/ci/parse_dotenv_artifact_service_spec.rb b/spec/services/ci/parse_dotenv_artifact_service_spec.rb index 7536e04f2de..c4040a426f2 100644 --- a/spec/services/ci/parse_dotenv_artifact_service_spec.rb +++ b/spec/services/ci/parse_dotenv_artifact_service_spec.rb @@ -45,7 +45,7 @@ RSpec.describe Ci::ParseDotenvArtifactService do it 'returns error' do expect(subject[:status]).to eq(:error) - expect(subject[:message]).to eq("Dotenv Artifact Too Big. Maximum Allowable Size: #{described_class::MAX_ACCEPTABLE_DOTENV_SIZE}") + expect(subject[:message]).to eq("Dotenv Artifact Too Big. Maximum Allowable Size: #{service.send(:dotenv_size_limit)}") expect(subject[:http_status]).to eq(:bad_request) end end @@ -186,7 +186,7 @@ RSpec.describe Ci::ParseDotenvArtifactService do context 'when more than limitated variables are specified in dotenv' do let(:blob) do StringIO.new.tap do |s| - (described_class::MAX_ACCEPTABLE_VARIABLES_COUNT + 1).times do |i| + (service.send(:dotenv_variable_limit) + 1).times do |i| s << "KEY#{i}=VAR#{i}\n" end end.string @@ -194,7 +194,7 @@ RSpec.describe Ci::ParseDotenvArtifactService do it 'returns error' do expect(subject[:status]).to eq(:error) - expect(subject[:message]).to eq("Dotenv files cannot have more than #{described_class::MAX_ACCEPTABLE_VARIABLES_COUNT} variables") + expect(subject[:message]).to eq("Dotenv files cannot have more than #{service.send(:dotenv_variable_limit)} variables") expect(subject[:http_status]).to eq(:bad_request) end end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 15c88c9f657..16635c64434 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -323,6 +323,37 @@ RSpec.describe Ci::RetryBuildService do it 'persists expanded environment name' do expect(new_build.metadata.expanded_environment_name).to eq('production') end + + it 'does not create a new environment' do + expect { new_build }.not_to change { Environment.count } + end + end + + context 'when build with dynamic environment is retried' do + let_it_be(:other_developer) { create(:user).tap { |u| project.add_developer(other_developer) } } + + let(:environment_name) { 'review/$CI_COMMIT_REF_SLUG-$GITLAB_USER_ID' } + + let!(:build) do + create(:ci_build, :with_deployment, environment: environment_name, + options: { environment: { name: environment_name } }, + pipeline: pipeline, stage_id: stage.id, project: project, + user: other_developer) + end + + it 're-uses the previous persisted environment' do + expect(build.persisted_environment.name).to eq("review/#{build.ref}-#{other_developer.id}") + + expect(new_build.persisted_environment.name).to eq("review/#{build.ref}-#{other_developer.id}") + end + + it 'creates a new deployment' do + expect { new_build }.to change { Deployment.count }.by(1) + end + + it 'does not create a new environment' do + expect { new_build }.not_to change { Environment.count } + end end context 'when build has needs' do diff --git a/spec/services/ci/unlock_artifacts_service_spec.rb b/spec/services/ci/unlock_artifacts_service_spec.rb index 8d289a867ba..8ee07fc44c8 100644 --- a/spec/services/ci/unlock_artifacts_service_spec.rb +++ b/spec/services/ci/unlock_artifacts_service_spec.rb @@ -3,93 +3,247 @@ require 'spec_helper' RSpec.describe Ci::UnlockArtifactsService do - describe '#execute' do - subject(:execute) { described_class.new(pipeline.project, pipeline.user).execute(ci_ref, before_pipeline) } + using RSpec::Parameterized::TableSyntax + + where(:tag, :ci_update_unlocked_job_artifacts) do + false | false + false | true + true | false + true | true + end + + with_them do + let(:ref) { 'master' } + let(:ref_path) { tag ? "#{::Gitlab::Git::TAG_REF_PREFIX}#{ref}" : "#{::Gitlab::Git::BRANCH_REF_PREFIX}#{ref}" } + let(:ci_ref) { create(:ci_ref, ref_path: ref_path) } + let(:project) { ci_ref.project } + let(:source_job) { create(:ci_build, pipeline: pipeline) } + + let!(:old_unlocked_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, project: project, locked: :unlocked) } + let!(:older_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, project: project, locked: :artifacts_locked) } + let!(:older_ambiguous_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: !tag, project: project, locked: :artifacts_locked) } + let!(:pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, project: project, locked: :artifacts_locked) } + let!(:child_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, project: project, locked: :artifacts_locked) } + let!(:newer_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: ref, tag: tag, project: project, locked: :artifacts_locked) } + let!(:other_ref_pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: 'other_ref', tag: tag, project: project, locked: :artifacts_locked) } + let!(:sources_pipeline) { create(:ci_sources_pipeline, source_job: source_job, source_project: project, pipeline: child_pipeline, project: project) } before do stub_const("#{described_class}::BATCH_SIZE", 1) + stub_feature_flags(ci_update_unlocked_job_artifacts: ci_update_unlocked_job_artifacts) end - [true, false].each do |tag| - context "when tag is #{tag}" do - let(:ref) { 'master' } - let(:ref_path) { tag ? "#{::Gitlab::Git::TAG_REF_PREFIX}#{ref}" : "#{::Gitlab::Git::BRANCH_REF_PREFIX}#{ref}" } - let(:ci_ref) { create(:ci_ref, ref_path: ref_path) } + describe '#execute' do + subject(:execute) { described_class.new(pipeline.project, pipeline.user).execute(ci_ref, before_pipeline) } + + context 'when running on a ref before a pipeline' do + let(:before_pipeline) { pipeline } + + it 'unlocks artifacts from older pipelines' do + expect { execute }.to change { older_pipeline.reload.locked }.from('artifacts_locked').to('unlocked') + end + + it 'does not unlock artifacts for tag or branch with same name as ref' do + expect { execute }.not_to change { older_ambiguous_pipeline.reload.locked }.from('artifacts_locked') + end + + it 'does not unlock artifacts from newer pipelines' do + expect { execute }.not_to change { newer_pipeline.reload.locked }.from('artifacts_locked') + end + + it 'does not lock artifacts from old unlocked pipelines' do + expect { execute }.not_to change { old_unlocked_pipeline.reload.locked }.from('unlocked') + end + + it 'does not unlock artifacts from the same pipeline' do + expect { execute }.not_to change { pipeline.reload.locked }.from('artifacts_locked') + end - let!(:old_unlocked_pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :unlocked) } - let!(:older_pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :artifacts_locked) } - let!(:older_ambiguous_pipeline) { create(:ci_pipeline, ref: ref, tag: !tag, project: ci_ref.project, locked: :artifacts_locked) } - let!(:pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :artifacts_locked) } - let!(:child_pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :artifacts_locked) } - let!(:newer_pipeline) { create(:ci_pipeline, ref: ref, tag: tag, project: ci_ref.project, locked: :artifacts_locked) } - let!(:other_ref_pipeline) { create(:ci_pipeline, ref: 'other_ref', tag: tag, project: ci_ref.project, locked: :artifacts_locked) } + it 'does not unlock artifacts for other refs' do + expect { execute }.not_to change { other_ref_pipeline.reload.locked }.from('artifacts_locked') + end - before do - create(:ci_sources_pipeline, - source_job: create(:ci_build, pipeline: pipeline), - source_project: ci_ref.project, - pipeline: child_pipeline, - project: ci_ref.project) + it 'does not unlock artifacts for child pipeline' do + expect { execute }.not_to change { child_pipeline.reload.locked }.from('artifacts_locked') end - context 'when running on a ref before a pipeline' do - let(:before_pipeline) { pipeline } + it 'unlocks job artifact records' do + pending unless ci_update_unlocked_job_artifacts - it 'unlocks artifacts from older pipelines' do - expect { execute }.to change { older_pipeline.reload.locked }.from('artifacts_locked').to('unlocked') - end + expect { execute }.to change { ::Ci::JobArtifact.artifact_unlocked.count }.from(0).to(2) + end + end - it 'does not unlock artifacts for tag or branch with same name as ref' do - expect { execute }.not_to change { older_ambiguous_pipeline.reload.locked }.from('artifacts_locked') - end + context 'when running on just the ref' do + let(:before_pipeline) { nil } - it 'does not unlock artifacts from newer pipelines' do - expect { execute }.not_to change { newer_pipeline.reload.locked }.from('artifacts_locked') - end + it 'unlocks artifacts from older pipelines' do + expect { execute }.to change { older_pipeline.reload.locked }.from('artifacts_locked').to('unlocked') + end - it 'does not lock artifacts from old unlocked pipelines' do - expect { execute }.not_to change { old_unlocked_pipeline.reload.locked }.from('unlocked') - end + it 'unlocks artifacts from newer pipelines' do + expect { execute }.to change { newer_pipeline.reload.locked }.from('artifacts_locked').to('unlocked') + end - it 'does not unlock artifacts from the same pipeline' do - expect { execute }.not_to change { pipeline.reload.locked }.from('artifacts_locked') - end + it 'unlocks artifacts from the same pipeline' do + expect { execute }.to change { pipeline.reload.locked }.from('artifacts_locked').to('unlocked') + end - it 'does not unlock artifacts for other refs' do - expect { execute }.not_to change { other_ref_pipeline.reload.locked }.from('artifacts_locked') - end + it 'does not unlock artifacts for tag or branch with same name as ref' do + expect { execute }.not_to change { older_ambiguous_pipeline.reload.locked }.from('artifacts_locked') + end - it 'does not unlock artifacts for child pipeline' do - expect { execute }.not_to change { child_pipeline.reload.locked }.from('artifacts_locked') - end + it 'does not lock artifacts from old unlocked pipelines' do + expect { execute }.not_to change { old_unlocked_pipeline.reload.locked }.from('unlocked') end - context 'when running on just the ref' do - let(:before_pipeline) { nil } + it 'does not unlock artifacts for other refs' do + expect { execute }.not_to change { other_ref_pipeline.reload.locked }.from('artifacts_locked') + end - it 'unlocks artifacts from older pipelines' do - expect { execute }.to change { older_pipeline.reload.locked }.from('artifacts_locked').to('unlocked') - end + it 'unlocks job artifact records' do + pending unless ci_update_unlocked_job_artifacts - it 'unlocks artifacts from newer pipelines' do - expect { execute }.to change { newer_pipeline.reload.locked }.from('artifacts_locked').to('unlocked') - end + expect { execute }.to change { ::Ci::JobArtifact.artifact_unlocked.count }.from(0).to(8) + end + end + end - it 'unlocks artifacts from the same pipeline' do - expect { execute }.to change { pipeline.reload.locked }.from('artifacts_locked').to('unlocked') - end + describe '#unlock_pipelines_query' do + subject { described_class.new(pipeline.project, pipeline.user).unlock_pipelines_query(ci_ref, before_pipeline) } + + context 'when running on a ref before a pipeline' do + let(:before_pipeline) { pipeline } + + it 'produces the expected SQL string' do + expect(subject.squish).to eq <<~SQL.squish + UPDATE + "ci_pipelines" + SET + "locked" = 0 + WHERE + "ci_pipelines"."id" IN + (SELECT + "ci_pipelines"."id" + FROM + "ci_pipelines" + WHERE + "ci_pipelines"."ci_ref_id" = #{ci_ref.id} + AND "ci_pipelines"."locked" = 1 + AND (ci_pipelines.id < #{before_pipeline.id}) + AND "ci_pipelines"."id" NOT IN + (WITH RECURSIVE + "base_and_descendants" + AS + ((SELECT + "ci_pipelines".* + FROM + "ci_pipelines" + WHERE + "ci_pipelines"."id" = #{before_pipeline.id}) + UNION + (SELECT + "ci_pipelines".* + FROM + "ci_pipelines", + "base_and_descendants", + "ci_sources_pipelines" + WHERE + "ci_sources_pipelines"."pipeline_id" = "ci_pipelines"."id" + AND "ci_sources_pipelines"."source_pipeline_id" = "base_and_descendants"."id" + AND "ci_sources_pipelines"."source_project_id" = "ci_sources_pipelines"."project_id")) + SELECT + "id" + FROM + "base_and_descendants" + AS + "ci_pipelines") + LIMIT 1 + FOR UPDATE + SKIP LOCKED) + RETURNING ("ci_pipelines"."id") + SQL + end + end - it 'does not unlock artifacts for tag or branch with same name as ref' do - expect { execute }.not_to change { older_ambiguous_pipeline.reload.locked }.from('artifacts_locked') - end + context 'when running on just the ref' do + let(:before_pipeline) { nil } + + it 'produces the expected SQL string' do + expect(subject.squish).to eq <<~SQL.squish + UPDATE + "ci_pipelines" + SET + "locked" = 0 + WHERE + "ci_pipelines"."id" IN + (SELECT + "ci_pipelines"."id" + FROM + "ci_pipelines" + WHERE + "ci_pipelines"."ci_ref_id" = #{ci_ref.id} + AND "ci_pipelines"."locked" = 1 + LIMIT 1 + FOR UPDATE + SKIP LOCKED) + RETURNING + ("ci_pipelines"."id") + SQL + end + end + end - it 'does not lock artifacts from old unlocked pipelines' do - expect { execute }.not_to change { old_unlocked_pipeline.reload.locked }.from('unlocked') - end + describe '#unlock_job_artifacts_query' do + subject { described_class.new(pipeline.project, pipeline.user).unlock_job_artifacts_query(pipeline_ids) } + + context 'when running on a ref before a pipeline' do + let(:before_pipeline) { pipeline } + let(:pipeline_ids) { [older_pipeline.id] } + + it 'produces the expected SQL string' do + expect(subject.squish).to eq <<~SQL.squish + UPDATE + "ci_job_artifacts" + SET + "locked" = 0 + WHERE + "ci_job_artifacts"."job_id" IN + (SELECT + "ci_builds"."id" + FROM + "ci_builds" + WHERE + "ci_builds"."type" = 'Ci::Build' + AND "ci_builds"."commit_id" = #{older_pipeline.id}) + RETURNING + ("ci_job_artifacts"."id") + SQL + end + end - it 'does not unlock artifacts for other refs' do - expect { execute }.not_to change { other_ref_pipeline.reload.locked }.from('artifacts_locked') - end + context 'when running on just the ref' do + let(:before_pipeline) { nil } + let(:pipeline_ids) { [older_pipeline.id, newer_pipeline.id, pipeline.id] } + + it 'produces the expected SQL string' do + expect(subject.squish).to eq <<~SQL.squish + UPDATE + "ci_job_artifacts" + SET + "locked" = 0 + WHERE + "ci_job_artifacts"."job_id" IN + (SELECT + "ci_builds"."id" + FROM + "ci_builds" + WHERE + "ci_builds"."type" = 'Ci::Build' + AND "ci_builds"."commit_id" IN (#{pipeline_ids.join(', ')})) + RETURNING + ("ci_job_artifacts"."id") + SQL end end end diff --git a/spec/services/ci/update_build_state_service_spec.rb b/spec/services/ci/update_build_state_service_spec.rb index e4dd3d0500f..937b19beff5 100644 --- a/spec/services/ci/update_build_state_service_spec.rb +++ b/spec/services/ci/update_build_state_service_spec.rb @@ -118,7 +118,7 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .not_to have_received(:increment_error_counter) - .with(type: :chunks_invalid_checksum) + .with(error_reason: :chunks_invalid_checksum) end end @@ -188,7 +188,7 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .to have_received(:increment_error_counter) - .with(type: :chunks_invalid_checksum) + .with(error_reason: :chunks_invalid_checksum) end end @@ -210,11 +210,11 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .not_to have_received(:increment_error_counter) - .with(type: :chunks_invalid_checksum) + .with(error_reason: :chunks_invalid_checksum) expect(metrics) .not_to have_received(:increment_error_counter) - .with(type: :chunks_invalid_size) + .with(error_reason: :chunks_invalid_size) end context 'when using deprecated parameters' do @@ -235,11 +235,11 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .not_to have_received(:increment_error_counter) - .with(type: :chunks_invalid_checksum) + .with(error_reason: :chunks_invalid_checksum) expect(metrics) .not_to have_received(:increment_error_counter) - .with(type: :chunks_invalid_size) + .with(error_reason: :chunks_invalid_size) end end end @@ -262,11 +262,11 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .to have_received(:increment_error_counter) - .with(type: :chunks_invalid_checksum) + .with(error_reason: :chunks_invalid_checksum) expect(metrics) .to have_received(:increment_error_counter) - .with(type: :chunks_invalid_size) + .with(error_reason: :chunks_invalid_size) end end @@ -284,7 +284,7 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .to have_received(:increment_error_counter) - .with(type: :chunks_invalid_checksum) + .with(error_reason: :chunks_invalid_checksum) expect(metrics) .not_to have_received(:increment_trace_operation) @@ -292,7 +292,7 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .not_to have_received(:increment_error_counter) - .with(type: :chunks_invalid_size) + .with(error_reason: :chunks_invalid_size) end end @@ -376,7 +376,7 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .not_to have_received(:increment_error_counter) - .with(type: :chunks_invalid_checksum) + .with(error_reason: :chunks_invalid_checksum) end context 'when build pending state is outdated' do diff --git a/spec/services/clusters/agents/refresh_authorization_service_spec.rb b/spec/services/clusters/agents/refresh_authorization_service_spec.rb index 77ba81ea9c0..09bec7ae0e8 100644 --- a/spec/services/clusters/agents/refresh_authorization_service_spec.rb +++ b/spec/services/clusters/agents/refresh_authorization_service_spec.rb @@ -113,6 +113,16 @@ RSpec.describe Clusters::Agents::RefreshAuthorizationService do expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' }) end + context 'project does not belong to a group, and is authorizing itself' do + let(:root_ancestor) { create(:namespace) } + let(:added_project) { project } + + it 'creates an authorization record for the project' do + expect(subject).to be_truthy + expect(agent.authorized_projects).to contain_exactly(added_project) + end + end + context 'config contains too many projects' do before do stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 1) diff --git a/spec/services/clusters/applications/prometheus_health_check_service_spec.rb b/spec/services/clusters/applications/prometheus_health_check_service_spec.rb deleted file mode 100644 index e6c7b147ab7..00000000000 --- a/spec/services/clusters/applications/prometheus_health_check_service_spec.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::PrometheusHealthCheckService, '#execute' do - let(:service) { described_class.new(cluster) } - - subject { service.execute } - - RSpec.shared_examples 'no alert' do - it 'does not send alert' do - expect(Projects::Alerting::NotifyService).not_to receive(:new) - - subject - end - end - - RSpec.shared_examples 'sends alert' do - it 'sends an alert' do - expect_next_instance_of(Projects::Alerting::NotifyService) do |notify_service| - expect(notify_service).to receive(:execute).with(integration.token, integration) - end - - subject - end - end - - RSpec.shared_examples 'correct health stored' do - it 'stores the correct health of prometheus app' do - subject - - expect(prometheus.healthy).to eq(client_healthy) - end - end - - context 'when cluster is not project_type' do - let(:cluster) { create(:cluster, :instance) } - - it { expect { subject }.to raise_error(RuntimeError, 'Invalid cluster type. Only project types are allowed.') } - end - - context 'when cluster is project_type' do - let_it_be(:project) { create(:project) } - let_it_be(:integration) { create(:alert_management_http_integration, project: project) } - - let(:applications_prometheus_healthy) { true } - let(:prometheus) { create(:clusters_applications_prometheus, status: prometheus_status_value, healthy: applications_prometheus_healthy) } - let(:cluster) { create(:cluster, :project, application_prometheus: prometheus, projects: [project]) } - - context 'when prometheus not installed' do - let(:prometheus_status_value) { Clusters::Applications::Prometheus.state_machine.states[:installing].value } - - it { expect(subject).to eq(nil) } - include_examples 'no alert' - end - - context 'when prometheus installed' do - let(:prometheus_status_value) { Clusters::Applications::Prometheus.state_machine.states[:installed].value } - - before do - client = instance_double('PrometheusClient', healthy?: client_healthy) - expect(prometheus).to receive(:prometheus_client).and_return(client) - end - - context 'when newly unhealthy' do - let(:applications_prometheus_healthy) { true } - let(:client_healthy) { false } - - include_examples 'sends alert' - include_examples 'correct health stored' - end - - context 'when newly healthy' do - let(:applications_prometheus_healthy) { false } - let(:client_healthy) { true } - - include_examples 'no alert' - include_examples 'correct health stored' - end - - context 'when continuously unhealthy' do - let(:applications_prometheus_healthy) { false } - let(:client_healthy) { false } - - include_examples 'no alert' - include_examples 'correct health stored' - end - - context 'when continuously healthy' do - let(:applications_prometheus_healthy) { true } - let(:client_healthy) { true } - - include_examples 'no alert' - include_examples 'correct health stored' - end - - context 'when first health check and healthy' do - let(:applications_prometheus_healthy) { nil } - let(:client_healthy) { true } - - include_examples 'no alert' - include_examples 'correct health stored' - end - - context 'when first health check and not healthy' do - let(:applications_prometheus_healthy) { nil } - let(:client_healthy) { false } - - include_examples 'sends alert' - include_examples 'correct health stored' - end - end - end -end diff --git a/spec/services/clusters/cleanup/project_namespace_service_spec.rb b/spec/services/clusters/cleanup/project_namespace_service_spec.rb index 605aaea17e4..ec510b2e3c5 100644 --- a/spec/services/clusters/cleanup/project_namespace_service_spec.rb +++ b/spec/services/clusters/cleanup/project_namespace_service_spec.rb @@ -58,6 +58,19 @@ RSpec.describe Clusters::Cleanup::ProjectNamespaceService do subject end + + context 'when cluster.kubeclient is nil' do + let(:kubeclient_instance_double) { nil } + + it 'schedules ::ServiceAccountWorker' do + expect(Clusters::Cleanup::ServiceAccountWorker).to receive(:perform_async).with(cluster.id) + subject + end + + it 'deletes namespaces from database' do + expect { subject }.to change { cluster.kubernetes_namespaces.exists? }.from(true).to(false) + end + end end context 'when cluster has no namespaces' do diff --git a/spec/services/clusters/cleanup/service_account_service_spec.rb b/spec/services/clusters/cleanup/service_account_service_spec.rb index f256df1b2fc..adcdbd84da0 100644 --- a/spec/services/clusters/cleanup/service_account_service_spec.rb +++ b/spec/services/clusters/cleanup/service_account_service_spec.rb @@ -44,5 +44,13 @@ RSpec.describe Clusters::Cleanup::ServiceAccountService do it 'deletes cluster' do expect { subject }.to change { Clusters::Cluster.where(id: cluster.id).exists? }.from(true).to(false) end + + context 'when cluster.kubeclient is nil' do + let(:kubeclient_instance_double) { nil } + + it 'deletes cluster' do + expect { subject }.to change { Clusters::Cluster.where(id: cluster.id).exists? }.from(true).to(false) + end + end end end diff --git a/spec/services/clusters/integrations/prometheus_health_check_service_spec.rb b/spec/services/clusters/integrations/prometheus_health_check_service_spec.rb new file mode 100644 index 00000000000..9db3b9d2417 --- /dev/null +++ b/spec/services/clusters/integrations/prometheus_health_check_service_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Integrations::PrometheusHealthCheckService, '#execute' do + let(:service) { described_class.new(cluster) } + + subject { service.execute } + + RSpec.shared_examples 'no alert' do + it 'does not send alert' do + expect(Projects::Alerting::NotifyService).not_to receive(:new) + + subject + end + end + + RSpec.shared_examples 'sends alert' do + it 'sends an alert' do + expect_next_instance_of(Projects::Alerting::NotifyService) do |notify_service| + expect(notify_service).to receive(:execute).with(integration.token, integration) + end + + subject + end + end + + RSpec.shared_examples 'correct health stored' do + it 'stores the correct health of prometheus' do + subject + + expect(prometheus.healthy?).to eq(client_healthy) + end + end + + context 'when cluster is not project_type' do + let(:cluster) { create(:cluster, :instance) } + + it { expect { subject }.to raise_error(RuntimeError, 'Invalid cluster type. Only project types are allowed.') } + end + + context 'when cluster is project_type' do + let_it_be(:project) { create(:project) } + let_it_be(:integration) { create(:alert_management_http_integration, project: project) } + + let(:previous_health_status) { :healthy } + let(:prometheus) { create(:clusters_integrations_prometheus, enabled: prometheus_enabled, health_status: previous_health_status) } + let(:cluster) { create(:cluster, :project, integration_prometheus: prometheus, projects: [project]) } + + context 'when prometheus not enabled' do + let(:prometheus_enabled) { false } + + it { expect(subject).to eq(nil) } + include_examples 'no alert' + end + + context 'when prometheus enabled' do + let(:prometheus_enabled) { true } + + before do + client = instance_double('PrometheusClient', healthy?: client_healthy) + expect(prometheus).to receive(:prometheus_client).and_return(client) + end + + context 'when newly unhealthy' do + let(:previous_health_status) { :healthy } + let(:client_healthy) { false } + + include_examples 'sends alert' + include_examples 'correct health stored' + end + + context 'when newly healthy' do + let(:previous_health_status) { :unhealthy } + let(:client_healthy) { true } + + include_examples 'no alert' + include_examples 'correct health stored' + end + + context 'when continuously unhealthy' do + let(:previous_health_status) { :unhealthy } + let(:client_healthy) { false } + + include_examples 'no alert' + include_examples 'correct health stored' + end + + context 'when continuously healthy' do + let(:previous_health_status) { :healthy } + let(:client_healthy) { true } + + include_examples 'no alert' + include_examples 'correct health stored' + end + + context 'when first health check and healthy' do + let(:previous_health_status) { :unknown } + let(:client_healthy) { true } + + include_examples 'no alert' + include_examples 'correct health stored' + end + + context 'when first health check and not healthy' do + let(:previous_health_status) { :unknown } + let(:client_healthy) { false } + + include_examples 'sends alert' + include_examples 'correct health stored' + end + end + end +end diff --git a/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb b/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb index 20b0546effa..5f7afdf699a 100644 --- a/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb +++ b/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb @@ -39,7 +39,7 @@ RSpec.describe DependencyProxy::FindOrCreateBlobService do let(:blob_sha) { blob.file_name.sub('.gz', '') } it 'uses cached blob instead of downloading one' do - expect { subject }.to change { blob.reload.updated_at } + expect { subject }.to change { blob.reload.read_at } expect(subject[:status]).to eq(:success) expect(subject[:blob]).to be_a(DependencyProxy::Blob) diff --git a/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb b/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb index b3f88f91289..ef608c9b113 100644 --- a/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb +++ b/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb @@ -13,7 +13,7 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do let(:token) { Digest::SHA256.hexdigest('123') } let(:headers) do { - 'docker-content-digest' => dependency_proxy_manifest.digest, + DependencyProxy::Manifest::DIGEST_HEADER => dependency_proxy_manifest.digest, 'content-type' => dependency_proxy_manifest.content_type } end @@ -31,6 +31,14 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do end end + shared_examples 'returning no manifest' do + it 'returns a nil manifest' do + expect(subject[:status]).to eq(:success) + expect(subject[:from_cache]).to eq false + expect(subject[:manifest]).to be_nil + end + end + context 'when no manifest exists' do let_it_be(:image) { 'new-image' } @@ -40,7 +48,15 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do stub_manifest_download(image, tag, headers: headers) end - it_behaves_like 'downloading the manifest' + it_behaves_like 'returning no manifest' + + context 'with dependency_proxy_manifest_workhorse feature disabled' do + before do + stub_feature_flags(dependency_proxy_manifest_workhorse: false) + end + + it_behaves_like 'downloading the manifest' + end end context 'failed head request' do @@ -49,7 +65,15 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do stub_manifest_download(image, tag, headers: headers) end - it_behaves_like 'downloading the manifest' + it_behaves_like 'returning no manifest' + + context 'with dependency_proxy_manifest_workhorse feature disabled' do + before do + stub_feature_flags(dependency_proxy_manifest_workhorse: false) + end + + it_behaves_like 'downloading the manifest' + end end end @@ -60,7 +84,7 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do shared_examples 'using the cached manifest' do it 'uses cached manifest instead of downloading one', :aggregate_failures do - expect { subject }.to change { dependency_proxy_manifest.reload.updated_at } + expect { subject }.to change { dependency_proxy_manifest.reload.read_at } expect(subject[:status]).to eq(:success) expect(subject[:manifest]).to be_a(DependencyProxy::Manifest) @@ -76,16 +100,24 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do let(:content_type) { 'new-content-type' } before do - stub_manifest_head(image, tag, headers: { 'docker-content-digest' => digest, 'content-type' => content_type }) - stub_manifest_download(image, tag, headers: { 'docker-content-digest' => digest, 'content-type' => content_type }) + stub_manifest_head(image, tag, headers: { DependencyProxy::Manifest::DIGEST_HEADER => digest, 'content-type' => content_type }) + stub_manifest_download(image, tag, headers: { DependencyProxy::Manifest::DIGEST_HEADER => digest, 'content-type' => content_type }) end - it 'downloads the new manifest and updates the existing record', :aggregate_failures do - expect(subject[:status]).to eq(:success) - expect(subject[:manifest]).to eq(dependency_proxy_manifest) - expect(subject[:manifest].content_type).to eq(content_type) - expect(subject[:manifest].digest).to eq(digest) - expect(subject[:from_cache]).to eq false + it_behaves_like 'returning no manifest' + + context 'with dependency_proxy_manifest_workhorse feature disabled' do + before do + stub_feature_flags(dependency_proxy_manifest_workhorse: false) + end + + it 'downloads the new manifest and updates the existing record', :aggregate_failures do + expect(subject[:status]).to eq(:success) + expect(subject[:manifest]).to eq(dependency_proxy_manifest) + expect(subject[:manifest].content_type).to eq(content_type) + expect(subject[:manifest].digest).to eq(digest) + expect(subject[:from_cache]).to eq false + end end end @@ -96,7 +128,15 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do stub_manifest_download(image, tag, headers: headers) end - it_behaves_like 'downloading the manifest' + it_behaves_like 'returning no manifest' + + context 'with dependency_proxy_manifest_workhorse feature disabled' do + before do + stub_feature_flags(dependency_proxy_manifest_workhorse: false) + end + + it_behaves_like 'downloading the manifest' + end end context 'failed connection' do diff --git a/spec/services/dependency_proxy/head_manifest_service_spec.rb b/spec/services/dependency_proxy/head_manifest_service_spec.rb index 9c1e4d650f8..949a8eb3bee 100644 --- a/spec/services/dependency_proxy/head_manifest_service_spec.rb +++ b/spec/services/dependency_proxy/head_manifest_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe DependencyProxy::HeadManifestService do let(:content_type) { 'foo' } let(:headers) do { - 'docker-content-digest' => digest, + DependencyProxy::Manifest::DIGEST_HEADER => digest, 'content-type' => content_type } end diff --git a/spec/services/dependency_proxy/pull_manifest_service_spec.rb b/spec/services/dependency_proxy/pull_manifest_service_spec.rb index b3053174cc0..6018a3229fb 100644 --- a/spec/services/dependency_proxy/pull_manifest_service_spec.rb +++ b/spec/services/dependency_proxy/pull_manifest_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe DependencyProxy::PullManifestService do let(:digest) { '12345' } let(:content_type) { 'foo' } let(:headers) do - { 'docker-content-digest' => digest, 'content-type' => content_type } + { DependencyProxy::Manifest::DIGEST_HEADER => digest, 'content-type' => content_type } end subject { described_class.new(image, tag, token).execute_with_manifest(&method(:check_response)) } diff --git a/spec/services/deployments/archive_in_project_service_spec.rb b/spec/services/deployments/archive_in_project_service_spec.rb new file mode 100644 index 00000000000..d4039ee7b4a --- /dev/null +++ b/spec/services/deployments/archive_in_project_service_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Deployments::ArchiveInProjectService do + let_it_be(:project) { create(:project, :repository) } + + let(:service) { described_class.new(project, nil) } + + describe '#execute' do + subject { service.execute } + + context 'when there are archivable deployments' do + let!(:deployments) { create_list(:deployment, 3, project: project) } + let!(:deployment_refs) { deployments.map(&:ref_path) } + + before do + deployments.each(&:create_ref) + allow(Deployment).to receive(:archivables_in) { deployments } + end + + it 'returns result code' do + expect(subject[:result]).to eq(:archived) + expect(subject[:status]).to eq(:success) + expect(subject[:count]).to eq(3) + end + + it 'archives the deployment' do + expect(deployments.map(&:archived?)).to be_all(false) + expect(deployment_refs_exist?).to be_all(true) + + subject + + deployments.each(&:reload) + expect(deployments.map(&:archived?)).to be_all(true) + expect(deployment_refs_exist?).to be_all(false) + end + + context 'when ref does not exist by some reason' do + before do + project.repository.delete_refs(*deployment_refs) + end + + it 'does not raise an error' do + expect(deployment_refs_exist?).to be_all(false) + + expect { subject }.not_to raise_error + + expect(deployment_refs_exist?).to be_all(false) + end + end + + context 'when deployments_archive feature flag is disabled' do + before do + stub_feature_flags(deployments_archive: false) + end + + it 'does not do anything' do + expect(subject[:status]).to eq(:error) + expect(subject[:message]).to eq('Feature flag is not enabled') + end + end + + def deployment_refs_exist? + deployment_refs.map { |path| project.repository.ref_exists?(path) } + end + end + + context 'when there are no archivable deployments' do + before do + allow(Deployment).to receive(:archivables_in) { Deployment.none } + end + + it 'returns result code' do + expect(subject[:result]).to eq(:empty) + expect(subject[:status]).to eq(:success) + end + end + end +end diff --git a/spec/services/deployments/link_merge_requests_service_spec.rb b/spec/services/deployments/link_merge_requests_service_spec.rb index a5a13230d6f..62adc834733 100644 --- a/spec/services/deployments/link_merge_requests_service_spec.rb +++ b/spec/services/deployments/link_merge_requests_service_spec.rb @@ -32,6 +32,19 @@ RSpec.describe Deployments::LinkMergeRequestsService do end end + context 'when the deployment is for one of the production environments' do + it 'links merge requests' do + environment = + create(:environment, environment_type: 'production', name: 'production/gcp') + + deploy = create(:deployment, :success, environment: environment) + + expect(deploy).to receive(:link_merge_requests).once + + described_class.new(deploy).execute + end + end + context 'when the deployment failed' do it 'does nothing' do environment = create(:environment, name: 'foo') diff --git a/spec/services/emails/create_service_spec.rb b/spec/services/emails/create_service_spec.rb index 1396a1fce30..2fabf4ae66a 100644 --- a/spec/services/emails/create_service_spec.rb +++ b/spec/services/emails/create_service_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe Emails::CreateService do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } + let(:opts) { { email: 'new@email.com', user: user } } subject(:service) { described_class.new(user, opts) } @@ -22,7 +23,7 @@ RSpec.describe Emails::CreateService do it 'has the right user association' do service.execute - expect(user.emails).to eq(Email.where(opts)) + expect(user.emails).to include(Email.find_by(opts)) end end end diff --git a/spec/services/emails/destroy_service_spec.rb b/spec/services/emails/destroy_service_spec.rb index f8407be41e7..7dcf367016e 100644 --- a/spec/services/emails/destroy_service_spec.rb +++ b/spec/services/emails/destroy_service_spec.rb @@ -15,5 +15,15 @@ RSpec.describe Emails::DestroyService do expect(user.emails).not_to include(email) expect(response).to be true end + + context 'when it corresponds to the user primary email' do + let(:email) { user.emails.find_by!(email: user.email) } + + it 'does not remove the email and raises an exception' do + expect { service.execute(email) }.to raise_error(StandardError, 'Cannot delete primary email') + + expect(user.emails).to include(email) + end + end end end diff --git a/spec/services/error_tracking/collect_error_service_spec.rb b/spec/services/error_tracking/collect_error_service_spec.rb index ee9d0813e64..52d095148c8 100644 --- a/spec/services/error_tracking/collect_error_service_spec.rb +++ b/spec/services/error_tracking/collect_error_service_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' RSpec.describe ErrorTracking::CollectErrorService do let_it_be(:project) { create(:project) } - let_it_be(:parsed_event) { Gitlab::Json.parse(fixture_file('error_tracking/parsed_event.json')) } + let_it_be(:parsed_event_file) { 'error_tracking/parsed_event.json' } + let_it_be(:parsed_event) { Gitlab::Json.parse(fixture_file(parsed_event_file)) } subject { described_class.new(project, nil, event: parsed_event) } @@ -41,6 +42,14 @@ RSpec.describe ErrorTracking::CollectErrorService do expect(event.payload).to eq parsed_event end + context 'python sdk event' do + let(:parsed_event) { Gitlab::Json.parse(fixture_file('error_tracking/python_event.json')) } + + it 'creates a valid event' do + expect { subject.execute }.to change { ErrorTracking::ErrorEvent.count }.by(1) + end + end + context 'unusual payload' do let(:modified_event) { parsed_event } @@ -64,5 +73,25 @@ RSpec.describe ErrorTracking::CollectErrorService do end end end + + context 'go payload' do + let(:parsed_event) { Gitlab::Json.parse(fixture_file('error_tracking/go_parsed_event.json')) } + + it 'has correct values set' do + subject.execute + + event = ErrorTracking::ErrorEvent.last + error = event.error + + expect(error.name).to eq '*errors.errorString' + expect(error.description).to start_with 'Hello world' + expect(error.platform).to eq 'go' + + expect(event.description).to start_with 'Hello world' + expect(event.level).to eq 'error' + expect(event.environment).to eq 'Accumulate' + expect(event.payload).to eq parsed_event + end + end end end diff --git a/spec/services/google_cloud/service_accounts_service_spec.rb b/spec/services/google_cloud/service_accounts_service_spec.rb new file mode 100644 index 00000000000..a0d09affa72 --- /dev/null +++ b/spec/services/google_cloud/service_accounts_service_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GoogleCloud::ServiceAccountsService do + let_it_be(:project) { create(:project) } + + let(:service) { described_class.new(project) } + + describe 'find_for_project' do + context 'when a project does not have GCP service account vars' do + before do + project.variables.build(key: 'blah', value: 'foo', environment_scope: 'world') + project.save! + end + + it 'returns an empty list' do + expect(service.find_for_project.length).to eq(0) + end + end + + context 'when a project has GCP service account ci vars' do + before do + project.variables.build(environment_scope: '*', key: 'GCP_PROJECT_ID', value: 'prj1') + project.variables.build(environment_scope: '*', key: 'GCP_SERVICE_ACCOUNT_KEY', value: 'mock') + project.variables.build(environment_scope: 'staging', key: 'GCP_PROJECT_ID', value: 'prj2') + project.variables.build(environment_scope: 'staging', key: 'GCP_SERVICE_ACCOUNT', value: 'mock') + project.variables.build(environment_scope: 'production', key: 'GCP_PROJECT_ID', value: 'prj3') + project.variables.build(environment_scope: 'production', key: 'GCP_SERVICE_ACCOUNT', value: 'mock') + project.variables.build(environment_scope: 'production', key: 'GCP_SERVICE_ACCOUNT_KEY', value: 'mock') + project.save! + end + + it 'returns a list of service accounts' do + list = service.find_for_project + + aggregate_failures 'testing list of service accounts' do + expect(list.length).to eq(3) + + expect(list.first[:environment]).to eq('*') + expect(list.first[:gcp_project]).to eq('prj1') + expect(list.first[:service_account_exists]).to eq(false) + expect(list.first[:service_account_key_exists]).to eq(true) + + expect(list.second[:environment]).to eq('staging') + expect(list.second[:gcp_project]).to eq('prj2') + expect(list.second[:service_account_exists]).to eq(true) + expect(list.second[:service_account_key_exists]).to eq(false) + + expect(list.third[:environment]).to eq('production') + expect(list.third[:gcp_project]).to eq('prj3') + expect(list.third[:service_account_exists]).to eq(true) + expect(list.third[:service_account_key_exists]).to eq(true) + end + end + end + end +end diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb index bcba39b0eb4..7ea08131419 100644 --- a/spec/services/groups/create_service_spec.rb +++ b/spec/services/groups/create_service_spec.rb @@ -171,7 +171,7 @@ RSpec.describe Groups::CreateService, '#execute' do context 'with an active group-level integration' do let(:service) { described_class.new(user, group_params.merge(parent_id: group.id)) } - let!(:group_integration) { create(:prometheus_integration, group: group, project: nil, api_url: 'https://prometheus.group.com/') } + let!(:group_integration) { create(:prometheus_integration, :group, group: group, api_url: 'https://prometheus.group.com/') } let(:group) do create(:group).tap do |group| group.add_owner(user) @@ -186,7 +186,7 @@ RSpec.describe Groups::CreateService, '#execute' do context 'with an active subgroup' do let(:service) { described_class.new(user, group_params.merge(parent_id: subgroup.id)) } - let!(:subgroup_integration) { create(:prometheus_integration, group: subgroup, project: nil, api_url: 'https://prometheus.subgroup.com/') } + let!(:subgroup_integration) { create(:prometheus_integration, :group, group: subgroup, api_url: 'https://prometheus.subgroup.com/') } let(:subgroup) do create(:group, parent: group).tap do |subgroup| subgroup.add_owner(user) @@ -242,4 +242,41 @@ RSpec.describe Groups::CreateService, '#execute' do end end end + + describe 'invite team email' do + let(:service) { described_class.new(user, group_params) } + + before do + allow(Namespaces::InviteTeamEmailWorker).to receive(:perform_in) + end + + it 'is sent' do + group = service.execute + delay = Namespaces::InviteTeamEmailService::DELIVERY_DELAY_IN_MINUTES + expect(Namespaces::InviteTeamEmailWorker).to have_received(:perform_in).with(delay, group.id, user.id) + end + + context 'when group has not been persisted' do + let(:service) { described_class.new(user, group_params.merge(name: '')) } + + it 'not sent' do + expect(Namespaces::InviteTeamEmailWorker).not_to receive(:perform_in) + service.execute + end + end + + context 'when group is not root' do + let(:parent_group) { create :group } + let(:service) { described_class.new(user, group_params.merge(parent_id: parent_group.id)) } + + before do + parent_group.add_owner(user) + end + + it 'not sent' do + expect(Namespaces::InviteTeamEmailWorker).not_to receive(:perform_in) + service.execute + end + end + end end diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb index ad5c4364deb..292f2e2b86b 100644 --- a/spec/services/groups/import_export/import_service_spec.rb +++ b/spec/services/groups/import_export/import_service_spec.rb @@ -7,6 +7,10 @@ RSpec.describe Groups::ImportExport::ImportService do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } + before do + allow(GroupImportWorker).to receive(:with_status).and_return(GroupImportWorker) + end + context 'when the job can be successfully scheduled' do subject(:import_service) { described_class.new(group: group, user: user) } @@ -20,6 +24,8 @@ RSpec.describe Groups::ImportExport::ImportService do end it 'enqueues an import job' do + allow(GroupImportWorker).to receive(:with_status).and_return(GroupImportWorker) + expect(GroupImportWorker).to receive(:perform_async).with(user.id, group.id) import_service.async_execute diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index 8b506d2bc2c..35d46884f4d 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -153,7 +153,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do it 'adds an error on group' do transfer_service.execute(nil) - expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup with the same path.') + expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup or a project with the same path.') end end @@ -185,9 +185,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do context 'when projects have project namespaces' do let_it_be(:project1) { create(:project, :private, namespace: group) } - let_it_be(:project_namespace1) { create(:project_namespace, project: project1) } let_it_be(:project2) { create(:project, :private, namespace: group) } - let_it_be(:project_namespace2) { create(:project_namespace, project: project2) } it_behaves_like 'project namespace path is in sync with project path' do let(:group_full_path) { "#{group.path}" } @@ -241,7 +239,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do it 'adds an error on group' do transfer_service.execute(new_parent_group) - expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup with the same path.') + expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup or a project with the same path.') end end @@ -250,36 +248,45 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do let_it_be(:membership) { create(:group_member, :owner, group: new_parent_group, user: user) } let_it_be(:project) { create(:project, path: 'foo', namespace: new_parent_group) } - before do - group.update_attribute(:path, 'foo') - end - - it 'returns false' do - expect(transfer_service.execute(new_parent_group)).to be_falsy - end - it 'adds an error on group' do - transfer_service.execute(new_parent_group) - expect(transfer_service.error).to eq('Transfer failed: Validation failed: Group URL has already been taken') + expect(transfer_service.execute(new_parent_group)).to be_falsy + expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup or a project with the same path.') end - context 'when projects have project namespaces' do - let!(:project_namespace) { create(:project_namespace, project: project) } - + # currently when a project is created it gets a corresponding project namespace + # so we test the case where a project without a project namespace is transferred + # for backward compatibility + context 'without project namespace' do before do - transfer_service.execute(new_parent_group) + project_namespace = project.project_namespace + project.update_column(:project_namespace_id, nil) + project_namespace.delete end - it_behaves_like 'project namespace path is in sync with project path' do - let(:group_full_path) { "#{new_parent_group.full_path}" } - let(:projects_with_project_namespace) { [project] } + it 'adds an error on group' do + expect(project.reload.project_namespace).to be_nil + expect(transfer_service.execute(new_parent_group)).to be_falsy + expect(transfer_service.error).to eq('Transfer failed: Validation failed: Group URL has already been taken') end end end + context 'when projects have project namespaces' do + let_it_be(:project) { create(:project, path: 'foo', namespace: new_parent_group) } + + before do + transfer_service.execute(new_parent_group) + end + + it_behaves_like 'project namespace path is in sync with project path' do + let(:group_full_path) { "#{new_parent_group.full_path}" } + let(:projects_with_project_namespace) { [project] } + end + end + context 'when the group is allowed to be transferred' do let_it_be(:new_parent_group, reload: true) { create(:group, :public) } - let_it_be(:new_parent_group_integration) { create(:integrations_slack, group: new_parent_group, project: nil, webhook: 'http://new-group.slack.com') } + let_it_be(:new_parent_group_integration) { create(:integrations_slack, :group, group: new_parent_group, webhook: 'http://new-group.slack.com') } before do allow(PropagateIntegrationWorker).to receive(:perform_async) @@ -316,7 +323,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do context 'with an inherited integration' do let_it_be(:instance_integration) { create(:integrations_slack, :instance, webhook: 'http://project.slack.com') } - let_it_be(:group_integration) { create(:integrations_slack, group: group, project: nil, webhook: 'http://group.slack.com', inherit_from_id: instance_integration.id) } + let_it_be(:group_integration) { create(:integrations_slack, :group, group: group, webhook: 'http://group.slack.com', inherit_from_id: instance_integration.id) } it 'replaces inherited integrations', :aggregate_failures do expect(new_created_integration.webhook).to eq(new_parent_group_integration.webhook) @@ -326,7 +333,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do end context 'with a custom integration' do - let_it_be(:group_integration) { create(:integrations_slack, group: group, project: nil, webhook: 'http://group.slack.com') } + let_it_be(:group_integration) { create(:integrations_slack, :group, group: group, webhook: 'http://group.slack.com') } it 'does not updates the integrations', :aggregate_failures do expect { transfer_service.execute(new_parent_group) }.not_to change { group_integration.webhook } @@ -445,8 +452,6 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do context 'when transferring a group with project descendants' do let!(:project1) { create(:project, :repository, :private, namespace: group) } let!(:project2) { create(:project, :repository, :internal, namespace: group) } - let!(:project_namespace1) { create(:project_namespace, project: project1) } - let!(:project_namespace2) { create(:project_namespace, project: project2) } before do TestEnv.clean_test_path @@ -483,8 +488,6 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do let!(:project1) { create(:project, :repository, :public, namespace: group) } let!(:project2) { create(:project, :repository, :public, namespace: group) } let!(:new_parent_group) { create(:group, :private) } - let!(:project_namespace1) { create(:project_namespace, project: project1) } - let!(:project_namespace2) { create(:project_namespace, project: project2) } it 'updates projects visibility to match the new parent' do group.projects.each do |project| @@ -504,8 +507,6 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do let!(:project2) { create(:project, :repository, :internal, namespace: group) } let!(:subgroup1) { create(:group, :private, parent: group) } let!(:subgroup2) { create(:group, :internal, parent: group) } - let!(:project_namespace1) { create(:project_namespace, project: project1) } - let!(:project_namespace2) { create(:project_namespace, project: project2) } before do TestEnv.clean_test_path @@ -593,11 +594,16 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do let_it_be_with_reload(:group) { create(:group, :private, parent: old_parent_group) } let_it_be(:new_group_member) { create(:user) } let_it_be(:old_group_member) { create(:user) } + let_it_be(:unique_subgroup_member) { create(:user) } + let_it_be(:direct_project_member) { create(:user) } before do new_parent_group.add_maintainer(new_group_member) old_parent_group.add_maintainer(old_group_member) + subgroup1.add_developer(unique_subgroup_member) + nested_project.add_developer(direct_project_member) group.refresh_members_authorized_projects + subgroup1.refresh_members_authorized_projects end it 'removes old project authorizations' do @@ -613,7 +619,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do end it 'performs authorizations job immediately' do - expect(AuthorizedProjectsWorker).to receive(:bulk_perform_inline) + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to receive(:bulk_perform_inline) transfer_service.execute(new_parent_group) end @@ -630,14 +636,24 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do ProjectAuthorization.where(project_id: nested_project.id, user_id: new_group_member.id).size }.from(0).to(1) end + + it 'preserves existing project authorizations for direct project members' do + expect { transfer_service.execute(new_parent_group) }.not_to change { + ProjectAuthorization.where(project_id: nested_project.id, user_id: direct_project_member.id).count + } + end end - context 'for groups with many members' do - before do - 11.times do - new_parent_group.add_maintainer(create(:user)) - end + context 'for nested groups with unique members' do + it 'preserves existing project authorizations' do + expect { transfer_service.execute(new_parent_group) }.not_to change { + ProjectAuthorization.where(project_id: nested_project.id, user_id: unique_subgroup_member.id).count + } end + end + + context 'for groups with many projects' do + let_it_be(:project_list) { create_list(:project, 11, :repository, :private, namespace: group) } it 'adds new project authorizations for the user which makes a transfer' do transfer_service.execute(new_parent_group) @@ -646,9 +662,21 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do expect(ProjectAuthorization.where(project_id: nested_project.id, user_id: user.id).size).to eq(1) end + it 'adds project authorizations for users in the new hierarchy' do + expect { transfer_service.execute(new_parent_group) }.to change { + ProjectAuthorization.where(project_id: project_list.map { |project| project.id }, user_id: new_group_member.id).size + }.from(0).to(project_list.count) + end + + it 'removes project authorizations for users in the old hierarchy' do + expect { transfer_service.execute(new_parent_group) }.to change { + ProjectAuthorization.where(project_id: project_list.map { |project| project.id }, user_id: old_group_member.id).size + }.from(project_list.count).to(0) + end + it 'schedules authorizations job' do - expect(AuthorizedProjectsWorker).to receive(:bulk_perform_async) - .with(array_including(new_parent_group.members_with_parents.pluck(:user_id).map {|id| [id, anything] })) + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to receive(:bulk_perform_async) + .with(array_including(group.all_projects.ids.map { |id| [id, anything] })) transfer_service.execute(new_parent_group) end diff --git a/spec/services/import/github/notes/create_service_spec.rb b/spec/services/import/github/notes/create_service_spec.rb new file mode 100644 index 00000000000..57699def848 --- /dev/null +++ b/spec/services/import/github/notes/create_service_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Import::Github::Notes::CreateService do + it 'does not support quick actions' do + project = create(:project, :repository) + user = create(:user) + merge_request = create(:merge_request, source_project: project) + + project.add_maintainer(user) + + note = described_class.new( + project, + user, + note: '/close', + noteable_type: 'MergeRequest', + noteable_id: merge_request.id + ).execute + + expect(note.note).to eq('/close') + expect(note.noteable.closed?).to be(false) + end +end diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index b96dd981e0f..cf75efb5c57 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -7,12 +7,14 @@ RSpec.describe Issues::BuildService do let_it_be(:project) { create(:project, :repository) } let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user) } let_it_be(:guest) { create(:user) } let(:user) { developer } before_all do project.add_developer(developer) + project.add_reporter(reporter) project.add_guest(guest) end @@ -140,76 +142,64 @@ RSpec.describe Issues::BuildService do end describe '#execute' do - context 'as developer' do - it 'builds a new issues with given params' do - milestone = create(:milestone, project: project) - issue = build_issue(milestone_id: milestone.id) - - expect(issue.milestone).to eq(milestone) - expect(issue.issue_type).to eq('issue') - expect(issue.work_item_type.base_type).to eq('issue') - end + describe 'setting milestone' do + context 'when developer' do + it 'builds a new issues with given params' do + milestone = create(:milestone, project: project) + issue = build_issue(milestone_id: milestone.id) + + expect(issue.milestone).to eq(milestone) + end - it 'sets milestone to nil if it is not available for the project' do - milestone = create(:milestone, project: create(:project)) - issue = build_issue(milestone_id: milestone.id) + it 'sets milestone to nil if it is not available for the project' do + milestone = create(:milestone, project: create(:project)) + issue = build_issue(milestone_id: milestone.id) - expect(issue.milestone).to be_nil + expect(issue.milestone).to be_nil + end end - context 'when issue_type is incident' do - it 'sets the correct issue type' do - issue = build_issue(issue_type: 'incident') + context 'when guest' do + let(:user) { guest } - expect(issue.issue_type).to eq('incident') - expect(issue.work_item_type.base_type).to eq('incident') + it 'cannot set milestone' do + milestone = create(:milestone, project: project) + issue = build_issue(milestone_id: milestone.id) + + expect(issue.milestone).to be_nil end end end - context 'as guest' do - let(:user) { guest } - - it 'cannot set milestone' do - milestone = create(:milestone, project: project) - issue = build_issue(milestone_id: milestone.id) + describe 'setting issue type' do + context 'with a corresponding WorkItem::Type' do + let_it_be(:type_issue_id) { WorkItem::Type.default_issue_type.id } + let_it_be(:type_incident_id) { WorkItem::Type.default_by_type(:incident).id } + + where(:issue_type, :current_user, :work_item_type_id, :resulting_issue_type) do + nil | ref(:guest) | ref(:type_issue_id) | 'issue' + 'issue' | ref(:guest) | ref(:type_issue_id) | 'issue' + 'incident' | ref(:guest) | ref(:type_issue_id) | 'issue' + 'incident' | ref(:reporter) | ref(:type_incident_id) | 'incident' + # update once support for test_case is enabled + 'test_case' | ref(:guest) | ref(:type_issue_id) | 'issue' + # update once support for requirement is enabled + 'requirement' | ref(:guest) | ref(:type_issue_id) | 'issue' + 'invalid' | ref(:guest) | ref(:type_issue_id) | 'issue' + # ensure that we don't set a value which has a permission check but is an invalid issue type + 'project' | ref(:guest) | ref(:type_issue_id) | 'issue' + end - expect(issue.milestone).to be_nil - end + with_them do + let(:user) { current_user } - context 'setting issue type' do - shared_examples 'builds an issue' do - specify do + it 'builds an issue' do issue = build_issue(issue_type: issue_type) expect(issue.issue_type).to eq(resulting_issue_type) expect(issue.work_item_type_id).to eq(work_item_type_id) end end - - it 'cannot set invalid issue type' do - issue = build_issue(issue_type: 'project') - - expect(issue).to be_issue - end - - context 'with a corresponding WorkItem::Type' do - let_it_be(:type_issue_id) { WorkItem::Type.default_issue_type.id } - let_it_be(:type_incident_id) { WorkItem::Type.default_by_type(:incident).id } - - where(:issue_type, :work_item_type_id, :resulting_issue_type) do - nil | ref(:type_issue_id) | 'issue' - 'issue' | ref(:type_issue_id) | 'issue' - 'incident' | ref(:type_incident_id) | 'incident' - 'test_case' | ref(:type_issue_id) | 'issue' # update once support for test_case is enabled - 'requirement' | ref(:type_issue_id) | 'issue' # update once support for requirement is enabled - 'invalid' | ref(:type_issue_id) | 'issue' - end - - with_them do - it_behaves_like 'builds an issue' - end - end end end end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 93ef046a632..158f9dec83e 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -83,6 +83,14 @@ RSpec.describe Issues::CloseService do service.execute(issue) end + it 'does not change escalation status' do + resolved = IncidentManagement::Escalatable::STATUSES[:resolved] + + expect { service.execute(issue) } + .to not_change { IncidentManagement::IssuableEscalationStatus.where(issue: issue).count } + .and not_change { IncidentManagement::IssuableEscalationStatus.where(status: resolved).count } + end + context 'issue is incident type' do let(:issue) { create(:incident, project: project) } let(:current_user) { user } @@ -90,6 +98,40 @@ RSpec.describe Issues::CloseService do subject { service.execute(issue) } it_behaves_like 'an incident management tracked event', :incident_management_incident_closed + + it 'creates a new escalation resolved escalation status', :aggregate_failures do + expect { service.execute(issue) }.to change { IncidentManagement::IssuableEscalationStatus.where(issue: issue).count }.by(1) + + expect(issue.incident_management_issuable_escalation_status).to be_resolved + end + + context 'when there is an escalation status' do + before do + create(:incident_management_issuable_escalation_status, issue: issue) + end + + it 'changes escalations status to resolved' do + expect { service.execute(issue) }.to change { issue.incident_management_issuable_escalation_status.reload.resolved? }.to(true) + end + + it 'adds a system note', :aggregate_failures do + expect { service.execute(issue) }.to change { issue.notes.count }.by(1) + + new_note = issue.notes.last + expect(new_note.note).to eq('changed the status to **Resolved** by closing the incident') + expect(new_note.author).to eq(user) + end + + context 'when the escalation status did not change to resolved' do + let(:escalation_status) { instance_double('IncidentManagement::IssuableEscalationStatus', resolve: false) } + + it 'does not create a system note' do + allow(issue).to receive(:incident_management_issuable_escalation_status).and_return(escalation_status) + + expect { service.execute(issue) }.not_to change { issue.notes.count } + end + end + end end end @@ -237,7 +279,7 @@ RSpec.describe Issues::CloseService do it 'verifies the number of queries' do recorded = ActiveRecord::QueryRecorder.new { close_issue } - expected_queries = 27 + expected_queries = 32 expect(recorded.count).to be <= expected_queries expect(recorded.cached_count).to eq(0) diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 1887be4896e..18e03db11dc 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -15,8 +15,7 @@ RSpec.describe Issues::CreateService do expect(described_class.rate_limiter_scoped_and_keyed).to be_a(RateLimitedService::RateLimiterScopedAndKeyed) expect(described_class.rate_limiter_scoped_and_keyed.key).to eq(:issues_create) - expect(described_class.rate_limiter_scoped_and_keyed.opts[:scope]).to eq(%i[project current_user]) - expect(described_class.rate_limiter_scoped_and_keyed.opts[:users_allowlist].call).to eq(%w[support-bot]) + expect(described_class.rate_limiter_scoped_and_keyed.opts[:scope]).to eq(%i[project current_user external_author]) expect(described_class.rate_limiter_scoped_and_keyed.rate_limiter_klass).to eq(Gitlab::ApplicationRateLimiter) end end @@ -81,7 +80,7 @@ RSpec.describe Issues::CreateService do it_behaves_like 'not an incident issue' - context 'issue is incident type' do + context 'when issue is incident type' do before do opts.merge!(issue_type: 'incident') end @@ -91,23 +90,37 @@ RSpec.describe Issues::CreateService do subject { issue } - it_behaves_like 'incident issue' - it_behaves_like 'has incident label' + context 'as reporter' do + let_it_be(:reporter) { create(:user) } - it 'does create an incident label' do - expect { subject } - .to change { Label.where(incident_label_attributes).count }.by(1) - end + let(:user) { reporter } - context 'when invalid' do - before do - opts.merge!(title: '') + before_all do + project.add_reporter(reporter) end - it 'does not apply an incident label prematurely' do - expect { subject }.to not_change(LabelLink, :count).and not_change(Issue, :count) + it_behaves_like 'incident issue' + it_behaves_like 'has incident label' + + it 'does create an incident label' do + expect { subject } + .to change { Label.where(incident_label_attributes).count }.by(1) + end + + context 'when invalid' do + before do + opts.merge!(title: '') + end + + it 'does not apply an incident label prematurely' do + expect { subject }.to not_change(LabelLink, :count).and not_change(Issue, :count) + end end end + + context 'as guest' do + it_behaves_like 'not an incident issue' + end end it 'refreshes the number of open issues', :use_clean_rails_memory_store_caching do @@ -289,6 +302,44 @@ RSpec.describe Issues::CreateService do described_class.new(project: project, current_user: user, params: opts, spam_params: spam_params).execute end + context 'when rate limiting is in effect', :freeze_time, :clean_gitlab_redis_rate_limiting do + let(:user) { create(:user) } + + before do + stub_feature_flags(rate_limited_service_issues_create: true) + stub_application_setting(issues_create_limit: 1) + end + + subject do + 2.times { described_class.new(project: project, current_user: user, params: opts, spam_params: double).execute } + end + + context 'when too many requests are sent by one user' do + it 'raises an error' do + expect do + subject + end.to raise_error(RateLimitedService::RateLimitedError) + end + + it 'creates 1 issue' do + expect do + subject + rescue RateLimitedService::RateLimitedError + end.to change { Issue.count }.by(1) + end + end + + context 'when limit is higher than count of issues being created' do + before do + stub_application_setting(issues_create_limit: 2) + end + + it 'creates 2 issues' do + expect { subject }.to change { Issue.count }.by(2) + end + end + end + context 'after_save callback to store_mentions' do context 'when mentionable attributes change' do let(:opts) { { title: 'Title', description: "Description with #{user.to_reference}" } } diff --git a/spec/services/issues/set_crm_contacts_service_spec.rb b/spec/services/issues/set_crm_contacts_service_spec.rb new file mode 100644 index 00000000000..65b22fe3b35 --- /dev/null +++ b/spec/services/issues/set_crm_contacts_service_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Issues::SetCrmContactsService do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:contacts) { create_list(:contact, 4, group: group) } + + let(:issue) { create(:issue, project: project) } + let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" } + + before do + create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]) + create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]) + end + + subject(:set_crm_contacts) do + described_class.new(project: project, current_user: user, params: params).execute(issue) + end + + describe '#execute' do + context 'when the user has no permission' do + let(:params) { { crm_contact_ids: [contacts[1].id, contacts[2].id] } } + + it 'returns expected error response' do + response = set_crm_contacts + + expect(response).to be_error + expect(response.message).to match_array(['You have insufficient permissions to set customer relations contacts for this issue']) + end + end + + context 'when user has permission' do + before do + group.add_reporter(user) + end + + context 'when the contact does not exist' do + let(:params) { { crm_contact_ids: [non_existing_record_id] } } + + it 'returns expected error response' do + response = set_crm_contacts + + expect(response).to be_error + expect(response.message).to match_array(["Issue customer relations contacts #{non_existing_record_id}: #{does_not_exist_or_no_permission}"]) + end + end + + context 'when the contact belongs to a different group' do + let(:group2) { create(:group) } + let(:contact) { create(:contact, group: group2) } + let(:params) { { crm_contact_ids: [contact.id] } } + + before do + group2.add_reporter(user) + end + + it 'returns expected error response' do + response = set_crm_contacts + + expect(response).to be_error + expect(response.message).to match_array(["Issue customer relations contacts #{contact.id}: #{does_not_exist_or_no_permission}"]) + end + end + + context 'replace' do + let(:params) { { crm_contact_ids: [contacts[1].id, contacts[2].id] } } + + it 'updates the issue with correct contacts' do + response = set_crm_contacts + + expect(response).to be_success + expect(issue.customer_relations_contacts).to match_array([contacts[1], contacts[2]]) + end + end + + context 'add' do + let(:params) { { add_crm_contact_ids: [contacts[3].id] } } + + it 'updates the issue with correct contacts' do + response = set_crm_contacts + + expect(response).to be_success + expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[1], contacts[3]]) + end + end + + context 'remove' do + let(:params) { { remove_crm_contact_ids: [contacts[0].id] } } + + it 'updates the issue with correct contacts' do + response = set_crm_contacts + + expect(response).to be_success + expect(issue.customer_relations_contacts).to match_array([contacts[1]]) + end + end + + context 'when attempting to add more than 6' do + let(:id) { contacts[0].id } + let(:params) { { add_crm_contact_ids: [id, id, id, id, id, id, id] } } + + it 'returns expected error message' do + response = set_crm_contacts + + expect(response).to be_error + expect(response.message).to match_array(['You can only add up to 6 contacts at one time']) + end + end + + context 'when trying to remove non-existent contact' do + let(:params) { { remove_crm_contact_ids: [non_existing_record_id] } } + + it 'returns expected error message' do + response = set_crm_contacts + + expect(response).to be_success + expect(response.message).to be_nil + end + end + + context 'when combining params' do + let(:error_invalid_params) { 'You cannot combine crm_contact_ids with add_crm_contact_ids or remove_crm_contact_ids' } + + context 'add and remove' do + let(:params) { { remove_crm_contact_ids: [contacts[1].id], add_crm_contact_ids: [contacts[3].id] } } + + it 'updates the issue with correct contacts' do + response = set_crm_contacts + + expect(response).to be_success + expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[3]]) + end + end + + context 'replace and remove' do + let(:params) { { crm_contact_ids: [contacts[3].id], remove_crm_contact_ids: [contacts[0].id] } } + + it 'returns expected error response' do + response = set_crm_contacts + + expect(response).to be_error + expect(response.message).to match_array([error_invalid_params]) + end + end + + context 'replace and add' do + let(:params) { { crm_contact_ids: [contacts[3].id], add_crm_contact_ids: [contacts[1].id] } } + + it 'returns expected error response' do + response = set_crm_contacts + + expect(response).to be_error + expect(response.message).to match_array([error_invalid_params]) + end + end + end + end + end +end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 83c17f051eb..85b8fef685e 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -1256,28 +1256,38 @@ RSpec.describe Issues::UpdateService, :mailer do let(:closed_issuable) { create(:closed_issue, project: project) } end - context 'real-time updates' do - using RSpec::Parameterized::TableSyntax - + context 'broadcasting issue assignee updates' do let(:update_params) { { assignee_ids: [user2.id] } } - where(:action_cable_in_app_enabled, :feature_flag_enabled, :should_broadcast) do - true | true | true - true | false | true - false | true | true - false | false | false - end + context 'when feature flag is enabled' do + before do + stub_feature_flags(broadcast_issue_updates: true) + end + + it 'triggers the GraphQL subscription' do + expect(GraphqlTriggers).to receive(:issuable_assignees_updated).with(issue) + + update_issue(update_params) + end - with_them do - it 'broadcasts to the issues channel based on ActionCable and feature flag values' do - allow(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(action_cable_in_app_enabled) - stub_feature_flags(broadcast_issue_updates: feature_flag_enabled) + context 'when assignee is not updated' do + let(:update_params) { { title: 'Some other title' } } - if should_broadcast - expect(GraphqlTriggers).to receive(:issuable_assignees_updated).with(issue) - else + it 'does not trigger the GraphQL subscription' do expect(GraphqlTriggers).not_to receive(:issuable_assignees_updated).with(issue) + + update_issue(update_params) end + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(broadcast_issue_updates: false) + end + + it 'does not trigger the GraphQL subscription' do + expect(GraphqlTriggers).not_to receive(:issuable_assignees_updated).with(issue) update_issue(update_params) end diff --git a/spec/services/labels/transfer_service_spec.rb b/spec/services/labels/transfer_service_spec.rb index 18fd401f383..05190accb33 100644 --- a/spec/services/labels/transfer_service_spec.rb +++ b/spec/services/labels/transfer_service_spec.rb @@ -3,107 +3,121 @@ require 'spec_helper' RSpec.describe Labels::TransferService do - describe '#execute' do - let_it_be(:user) { create(:user) } + shared_examples 'transfer labels' do + describe '#execute' do + let_it_be(:user) { create(:user) } - let_it_be(:old_group_ancestor) { create(:group) } - let_it_be(:old_group) { create(:group, parent: old_group_ancestor) } + let_it_be(:old_group_ancestor) { create(:group) } + let_it_be(:old_group) { create(:group, parent: old_group_ancestor) } - let_it_be(:new_group) { create(:group) } + let_it_be(:new_group) { create(:group) } - let_it_be(:project) { create(:project, :repository, group: new_group) } + let_it_be(:project) { create(:project, :repository, group: new_group) } - subject(:service) { described_class.new(user, old_group, project) } + subject(:service) { described_class.new(user, old_group, project) } - before do - old_group_ancestor.add_developer(user) - new_group.add_developer(user) - end + before do + old_group_ancestor.add_developer(user) + new_group.add_developer(user) + end - it 'recreates missing group labels at project level and assigns them to the issuables' do - old_group_label_1 = create(:group_label, group: old_group) - old_group_label_2 = create(:group_label, group: old_group) + it 'recreates missing group labels at project level and assigns them to the issuables' do + old_group_label_1 = create(:group_label, group: old_group) + old_group_label_2 = create(:group_label, group: old_group) - labeled_issue = create(:labeled_issue, project: project, labels: [old_group_label_1]) - labeled_merge_request = create(:labeled_merge_request, source_project: project, labels: [old_group_label_2]) + labeled_issue = create(:labeled_issue, project: project, labels: [old_group_label_1]) + labeled_merge_request = create(:labeled_merge_request, source_project: project, labels: [old_group_label_2]) - expect { service.execute }.to change(project.labels, :count).by(2) - expect(labeled_issue.reload.labels).to contain_exactly(project.labels.find_by_title(old_group_label_1.title)) - expect(labeled_merge_request.reload.labels).to contain_exactly(project.labels.find_by_title(old_group_label_2.title)) - end + expect { service.execute }.to change(project.labels, :count).by(2) + expect(labeled_issue.reload.labels).to contain_exactly(project.labels.find_by_title(old_group_label_1.title)) + expect(labeled_merge_request.reload.labels).to contain_exactly(project.labels.find_by_title(old_group_label_2.title)) + end - it 'recreates missing ancestor group labels at project level and assigns them to the issuables' do - old_group_ancestor_label_1 = create(:group_label, group: old_group_ancestor) - old_group_ancestor_label_2 = create(:group_label, group: old_group_ancestor) + it 'recreates missing ancestor group labels at project level and assigns them to the issuables' do + old_group_ancestor_label_1 = create(:group_label, group: old_group_ancestor) + old_group_ancestor_label_2 = create(:group_label, group: old_group_ancestor) - labeled_issue = create(:labeled_issue, project: project, labels: [old_group_ancestor_label_1]) - labeled_merge_request = create(:labeled_merge_request, source_project: project, labels: [old_group_ancestor_label_2]) + labeled_issue = create(:labeled_issue, project: project, labels: [old_group_ancestor_label_1]) + labeled_merge_request = create(:labeled_merge_request, source_project: project, labels: [old_group_ancestor_label_2]) - expect { service.execute }.to change(project.labels, :count).by(2) - expect(labeled_issue.reload.labels).to contain_exactly(project.labels.find_by_title(old_group_ancestor_label_1.title)) - expect(labeled_merge_request.reload.labels).to contain_exactly(project.labels.find_by_title(old_group_ancestor_label_2.title)) - end + expect { service.execute }.to change(project.labels, :count).by(2) + expect(labeled_issue.reload.labels).to contain_exactly(project.labels.find_by_title(old_group_ancestor_label_1.title)) + expect(labeled_merge_request.reload.labels).to contain_exactly(project.labels.find_by_title(old_group_ancestor_label_2.title)) + end - it 'recreates label priorities related to the missing group labels' do - old_group_label = create(:group_label, group: old_group) - create(:labeled_issue, project: project, labels: [old_group_label]) - create(:label_priority, project: project, label: old_group_label, priority: 1) + it 'recreates label priorities related to the missing group labels' do + old_group_label = create(:group_label, group: old_group) + create(:labeled_issue, project: project, labels: [old_group_label]) + create(:label_priority, project: project, label: old_group_label, priority: 1) - service.execute + service.execute - new_project_label = project.labels.find_by(title: old_group_label.title) - expect(new_project_label.id).not_to eq old_group_label.id - expect(new_project_label.priorities).not_to be_empty - end + new_project_label = project.labels.find_by(title: old_group_label.title) + expect(new_project_label.id).not_to eq old_group_label.id + expect(new_project_label.priorities).not_to be_empty + end - it 'does not recreate missing group labels that are not applied to issues or merge requests' do - old_group_label = create(:group_label, group: old_group) + it 'does not recreate missing group labels that are not applied to issues or merge requests' do + old_group_label = create(:group_label, group: old_group) - service.execute + service.execute - expect(project.labels.where(title: old_group_label.title)).to be_empty - end + expect(project.labels.where(title: old_group_label.title)).to be_empty + end - it 'does not recreate missing group labels that already exist in the project group' do - old_group_label = create(:group_label, group: old_group) - labeled_issue = create(:labeled_issue, project: project, labels: [old_group_label]) + it 'does not recreate missing group labels that already exist in the project group' do + old_group_label = create(:group_label, group: old_group) + labeled_issue = create(:labeled_issue, project: project, labels: [old_group_label]) - new_group_label = create(:group_label, group: new_group, title: old_group_label.title) + new_group_label = create(:group_label, group: new_group, title: old_group_label.title) - service.execute + service.execute - expect(project.labels.where(title: old_group_label.title)).to be_empty - expect(labeled_issue.reload.labels).to contain_exactly(new_group_label) - end + expect(project.labels.where(title: old_group_label.title)).to be_empty + expect(labeled_issue.reload.labels).to contain_exactly(new_group_label) + end - it 'updates only label links in the given project' do - old_group_label = create(:group_label, group: old_group) - other_project = create(:project, group: old_group) + it 'updates only label links in the given project' do + old_group_label = create(:group_label, group: old_group) + other_project = create(:project, group: old_group) - labeled_issue = create(:labeled_issue, project: project, labels: [old_group_label]) - other_project_labeled_issue = create(:labeled_issue, project: other_project, labels: [old_group_label]) + labeled_issue = create(:labeled_issue, project: project, labels: [old_group_label]) + other_project_labeled_issue = create(:labeled_issue, project: other_project, labels: [old_group_label]) - service.execute + service.execute - expect(labeled_issue.reload.labels).not_to include(old_group_label) - expect(other_project_labeled_issue.reload.labels).to contain_exactly(old_group_label) - end + expect(labeled_issue.reload.labels).not_to include(old_group_label) + expect(other_project_labeled_issue.reload.labels).to contain_exactly(old_group_label) + end - context 'when moving within the same ancestor group' do - let(:other_subgroup) { create(:group, parent: old_group_ancestor) } - let(:project) { create(:project, :repository, group: other_subgroup) } + context 'when moving within the same ancestor group' do + let(:other_subgroup) { create(:group, parent: old_group_ancestor) } + let(:project) { create(:project, :repository, group: other_subgroup) } - it 'does not recreate ancestor group labels' do - old_group_ancestor_label_1 = create(:group_label, group: old_group_ancestor) - old_group_ancestor_label_2 = create(:group_label, group: old_group_ancestor) + it 'does not recreate ancestor group labels' do + old_group_ancestor_label_1 = create(:group_label, group: old_group_ancestor) + old_group_ancestor_label_2 = create(:group_label, group: old_group_ancestor) - labeled_issue = create(:labeled_issue, project: project, labels: [old_group_ancestor_label_1]) - labeled_merge_request = create(:labeled_merge_request, source_project: project, labels: [old_group_ancestor_label_2]) + labeled_issue = create(:labeled_issue, project: project, labels: [old_group_ancestor_label_1]) + labeled_merge_request = create(:labeled_merge_request, source_project: project, labels: [old_group_ancestor_label_2]) - expect { service.execute }.not_to change(project.labels, :count) - expect(labeled_issue.reload.labels).to contain_exactly(old_group_ancestor_label_1) - expect(labeled_merge_request.reload.labels).to contain_exactly(old_group_ancestor_label_2) + expect { service.execute }.not_to change(project.labels, :count) + expect(labeled_issue.reload.labels).to contain_exactly(old_group_ancestor_label_1) + expect(labeled_merge_request.reload.labels).to contain_exactly(old_group_ancestor_label_2) + end end end end + + context 'with use_optimized_group_labels_query FF on' do + it_behaves_like 'transfer labels' + end + + context 'with use_optimized_group_labels_query FF off' do + before do + stub_feature_flags(use_optimized_group_labels_query: false) + end + + it_behaves_like 'transfer labels' + end end diff --git a/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb b/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb new file mode 100644 index 00000000000..bdb3d0f6700 --- /dev/null +++ b/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe LooseForeignKeys::BatchCleanerService do + include MigrationsHelpers + + def create_table_structure + migration = ActiveRecord::Migration.new.extend(Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers) + + migration.create_table :_test_loose_fk_parent_table + + migration.create_table :_test_loose_fk_child_table_1 do |t| + t.bigint :parent_id + end + + migration.create_table :_test_loose_fk_child_table_2 do |t| + t.bigint :parent_id_with_different_column + end + + migration.track_record_deletions(:_test_loose_fk_parent_table) + end + + let(:parent_model) do + Class.new(ApplicationRecord) do + self.table_name = '_test_loose_fk_parent_table' + + include LooseForeignKey + + loose_foreign_key :_test_loose_fk_child_table_1, :parent_id, on_delete: :async_delete + loose_foreign_key :_test_loose_fk_child_table_2, :parent_id_with_different_column, on_delete: :async_nullify + end + end + + let(:child_model_1) do + Class.new(ApplicationRecord) do + self.table_name = '_test_loose_fk_child_table_1' + end + end + + let(:child_model_2) do + Class.new(ApplicationRecord) do + self.table_name = '_test_loose_fk_child_table_2' + end + end + + let(:loose_fk_child_table_1) { table(:_test_loose_fk_child_table_1) } + let(:loose_fk_child_table_2) { table(:_test_loose_fk_child_table_2) } + let(:parent_record_1) { parent_model.create! } + let(:other_parent_record) { parent_model.create! } + + before(:all) do + create_table_structure + end + + before do + parent_record_1 + + loose_fk_child_table_1.create!(parent_id: parent_record_1.id) + loose_fk_child_table_1.create!(parent_id: parent_record_1.id) + + # these will not be deleted + loose_fk_child_table_1.create!(parent_id: other_parent_record.id) + loose_fk_child_table_1.create!(parent_id: other_parent_record.id) + + loose_fk_child_table_2.create!(parent_id_with_different_column: parent_record_1.id) + loose_fk_child_table_2.create!(parent_id_with_different_column: parent_record_1.id) + + # these will not be deleted + loose_fk_child_table_2.create!(parent_id_with_different_column: other_parent_record.id) + loose_fk_child_table_2.create!(parent_id_with_different_column: other_parent_record.id) + end + + after(:all) do + migration = ActiveRecord::Migration.new + migration.drop_table :_test_loose_fk_parent_table + migration.drop_table :_test_loose_fk_child_table_1 + migration.drop_table :_test_loose_fk_child_table_2 + end + + context 'when parent records are deleted' do + let(:deleted_records_counter) { Gitlab::Metrics.registry.get(:loose_foreign_key_processed_deleted_records) } + + before do + parent_record_1.delete + + expect(loose_fk_child_table_1.count).to eq(4) + expect(loose_fk_child_table_2.count).to eq(4) + + described_class.new(parent_klass: parent_model, + deleted_parent_records: LooseForeignKeys::DeletedRecord.status_pending.all, + models_by_table_name: { + '_test_loose_fk_child_table_1' => child_model_1, + '_test_loose_fk_child_table_2' => child_model_2 + }).execute + end + + it 'cleans up the child records' do + expect(loose_fk_child_table_1.where(parent_id: parent_record_1.id)).to be_empty + expect(loose_fk_child_table_2.where(parent_id_with_different_column: nil).count).to eq(2) + end + + it 'cleans up the pending parent DeletedRecord' do + expect(LooseForeignKeys::DeletedRecord.status_pending.count).to eq(0) + expect(LooseForeignKeys::DeletedRecord.status_processed.count).to eq(1) + end + + it 'records the DeletedRecord status updates', :prometheus do + counter = Gitlab::Metrics.registry.get(:loose_foreign_key_processed_deleted_records) + + expect(counter.get(table: parent_model.table_name, db_config_name: 'main')).to eq(1) + end + + it 'does not delete unrelated records' do + expect(loose_fk_child_table_1.where(parent_id: other_parent_record.id).count).to eq(2) + expect(loose_fk_child_table_2.where(parent_id_with_different_column: other_parent_record.id).count).to eq(2) + end + end +end diff --git a/spec/services/loose_foreign_keys/cleaner_service_spec.rb b/spec/services/loose_foreign_keys/cleaner_service_spec.rb new file mode 100644 index 00000000000..6f37ac49435 --- /dev/null +++ b/spec/services/loose_foreign_keys/cleaner_service_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe LooseForeignKeys::CleanerService do + let(:schema) { ApplicationRecord.connection.current_schema } + let(:deleted_records) do + [ + LooseForeignKeys::DeletedRecord.new(fully_qualified_table_name: "#{schema}.projects", primary_key_value: non_existing_record_id), + LooseForeignKeys::DeletedRecord.new(fully_qualified_table_name: "#{schema}.projects", primary_key_value: non_existing_record_id) + ] + end + + let(:loose_fk_definition) do + ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new( + 'projects', + 'issues', + { + column: 'project_id', + on_delete: :async_nullify + } + ) + end + + subject(:cleaner_service) do + described_class.new( + model: Issue, + foreign_key_definition: loose_fk_definition, + deleted_parent_records: deleted_records + ) + end + + context 'when invalid foreign key definition is passed' do + context 'when invalid on_delete argument was given' do + before do + loose_fk_definition.options[:on_delete] = :invalid + end + + it 'raises KeyError' do + expect { cleaner_service.execute }.to raise_error(StandardError, /Invalid on_delete argument/) + end + end + end + + describe 'query generation' do + context 'when single primary key is used' do + let(:issue) { create(:issue) } + + let(:deleted_records) do + [ + LooseForeignKeys::DeletedRecord.new(fully_qualified_table_name: "#{schema}.projects", primary_key_value: issue.project_id) + ] + end + + it 'generates an IN query for nullifying the rows' do + expected_query = %{UPDATE "issues" SET "project_id" = NULL WHERE ("issues"."id") IN (SELECT "issues"."id" FROM "issues" WHERE "issues"."project_id" IN (#{issue.project_id}) LIMIT 500)} + expect(ApplicationRecord.connection).to receive(:execute).with(expected_query).and_call_original + + cleaner_service.execute + + issue.reload + expect(issue.project_id).to be_nil + end + + it 'generates an IN query for deleting the rows' do + loose_fk_definition.options[:on_delete] = :async_delete + + expected_query = %{DELETE FROM "issues" WHERE ("issues"."id") IN (SELECT "issues"."id" FROM "issues" WHERE "issues"."project_id" IN (#{issue.project_id}) LIMIT 1000)} + expect(ApplicationRecord.connection).to receive(:execute).with(expected_query).and_call_original + + cleaner_service.execute + + expect(Issue.exists?(id: issue.id)).to eq(false) + end + end + + context 'when composite primary key is used' do + let!(:user) { create(:user) } + let!(:project) { create(:project) } + + let(:loose_fk_definition) do + ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new( + 'users', + 'project_authorizations', + { + column: 'user_id', + on_delete: :async_delete + } + ) + end + + let(:deleted_records) do + [ + LooseForeignKeys::DeletedRecord.new(fully_qualified_table_name: "#{schema}.users", primary_key_value: user.id) + ] + end + + subject(:cleaner_service) do + described_class.new( + model: ProjectAuthorization, + foreign_key_definition: loose_fk_definition, + deleted_parent_records: deleted_records + ) + end + + before do + project.add_developer(user) + end + + it 'generates an IN query for deleting the rows' do + expected_query = %{DELETE FROM "project_authorizations" WHERE ("project_authorizations"."user_id", "project_authorizations"."project_id", "project_authorizations"."access_level") IN (SELECT "project_authorizations"."user_id", "project_authorizations"."project_id", "project_authorizations"."access_level" FROM "project_authorizations" WHERE "project_authorizations"."user_id" IN (#{user.id}) LIMIT 1000)} + expect(ApplicationRecord.connection).to receive(:execute).with(expected_query).and_call_original + + cleaner_service.execute + + expect(ProjectAuthorization.exists?(user_id: user.id)).to eq(false) + end + + context 'when the query generation is incorrect (paranoid check)' do + it 'raises error if the foreign key condition is missing' do + expect_next_instance_of(LooseForeignKeys::CleanerService) do |instance| + expect(instance).to receive(:delete_query).and_return('wrong query') + end + + expect { cleaner_service.execute }.to raise_error /FATAL: foreign key condition is missing from the generated query/ + end + end + end + + context 'when with_skip_locked parameter is true' do + subject(:cleaner_service) do + described_class.new( + model: Issue, + foreign_key_definition: loose_fk_definition, + deleted_parent_records: deleted_records, + with_skip_locked: true + ) + end + + it 'generates a query with the SKIP LOCKED clause' do + expect(ApplicationRecord.connection).to receive(:execute).with(/FOR UPDATE SKIP LOCKED/).and_call_original + + cleaner_service.execute + end + end + end +end diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb index 2e6e6041fc3..fe866d73215 100644 --- a/spec/services/members/create_service_spec.rb +++ b/spec/services/members/create_service_spec.rb @@ -196,4 +196,108 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ end end end + + context 'when assigning tasks to be done' do + let(:additional_params) do + { invite_source: '_invite_source_', tasks_to_be_done: %w(ci code), tasks_project_id: source.id } + end + + before do + stub_experiments(invite_members_for_task: true) + end + + it 'creates 2 task issues', :aggregate_failures do + expect(TasksToBeDone::CreateWorker) + .to receive(:perform_async) + .with(anything, user.id, [member.id]) + .once + .and_call_original + expect { execute_service }.to change { source.issues.count }.by(2) + + expect(source.issues).to all have_attributes( + project: source, + author: user + ) + end + + context 'when passing many user ids' do + before do + stub_licensed_features(multiple_issue_assignees: false) + end + + let(:another_user) { create(:user) } + let(:user_ids) { [member.id, another_user.id].join(',') } + + it 'still creates 2 task issues', :aggregate_failures do + expect(TasksToBeDone::CreateWorker) + .to receive(:perform_async) + .with(anything, user.id, array_including(member.id, another_user.id)) + .once + .and_call_original + expect { execute_service }.to change { source.issues.count }.by(2) + + expect(source.issues).to all have_attributes( + project: source, + author: user + ) + end + end + + context 'when a `tasks_project_id` is missing' do + let(:additional_params) do + { invite_source: '_invite_source_', tasks_to_be_done: %w(ci code) } + end + + it 'does not create task issues' do + expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async) + execute_service + end + end + + context 'when `tasks_to_be_done` are missing' do + let(:additional_params) do + { invite_source: '_invite_source_', tasks_project_id: source.id } + end + + it 'does not create task issues' do + expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async) + execute_service + end + end + + context 'when invalid `tasks_to_be_done` are passed' do + let(:additional_params) do + { invite_source: '_invite_source_', tasks_project_id: source.id, tasks_to_be_done: %w(invalid_task) } + end + + it 'does not create task issues' do + expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async) + execute_service + end + end + + context 'when invalid `tasks_project_id` is passed' do + let(:another_project) { create(:project) } + let(:additional_params) do + { invite_source: '_invite_source_', tasks_project_id: another_project.id, tasks_to_be_done: %w(ci code) } + end + + it 'does not create task issues' do + expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async) + execute_service + end + end + + context 'when a member was already invited' do + let(:user_ids) { create(:project_member, :invited, project: source).invite_email } + let(:additional_params) do + { invite_source: '_invite_source_', tasks_project_id: source.id, tasks_to_be_done: %w(ci code) } + end + + it 'does not create task issues' do + expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async) + execute_service + end + end + end end diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb index 478733e8aa0..7b9ae19f038 100644 --- a/spec/services/members/invite_service_spec.rb +++ b/spec/services/members/invite_service_spec.rb @@ -22,6 +22,11 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ end it_behaves_like 'records an onboarding progress action', :user_added + + it 'does not create task issues' do + expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async) + expect { result }.not_to change { project.issues.count } + end end context 'when email belongs to an existing user as a secondary email' do diff --git a/spec/services/merge_requests/mergeability/run_checks_service_spec.rb b/spec/services/merge_requests/mergeability/run_checks_service_spec.rb index 170d99f4642..71ad23bc68c 100644 --- a/spec/services/merge_requests/mergeability/run_checks_service_spec.rb +++ b/spec/services/merge_requests/mergeability/run_checks_service_spec.rb @@ -8,7 +8,7 @@ RSpec.describe MergeRequests::Mergeability::RunChecksService do let_it_be(:merge_request) { create(:merge_request) } describe '#CHECKS' do - it 'contains every subclass of the base checks service' do + it 'contains every subclass of the base checks service', :eager_load do expect(described_class::CHECKS).to contain_exactly(*MergeRequests::Mergeability::CheckBaseService.subclasses) end end @@ -19,7 +19,7 @@ RSpec.describe MergeRequests::Mergeability::RunChecksService do let(:params) { {} } let(:success_result) { Gitlab::MergeRequests::Mergeability::CheckResult.success } - context 'when every check is skipped' do + context 'when every check is skipped', :eager_load do before do MergeRequests::Mergeability::CheckBaseService.subclasses.each do |subclass| expect_next_instance_of(subclass) do |service| diff --git a/spec/services/merge_requests/retarget_chain_service_spec.rb b/spec/services/merge_requests/retarget_chain_service_spec.rb index 87bde4a1400..187dd0cf589 100644 --- a/spec/services/merge_requests/retarget_chain_service_spec.rb +++ b/spec/services/merge_requests/retarget_chain_service_spec.rb @@ -45,14 +45,6 @@ RSpec.describe MergeRequests::RetargetChainService do .from(merge_request.source_branch) .to(merge_request.target_branch) end - - context 'when FF retarget_merge_requests is disabled' do - before do - stub_feature_flags(retarget_merge_requests: false) - end - - include_examples 'does not retarget merge request' - end end context 'in the same project' do diff --git a/spec/services/merge_requests/toggle_attention_requested_service_spec.rb b/spec/services/merge_requests/toggle_attention_requested_service_spec.rb new file mode 100644 index 00000000000..a26b1be529e --- /dev/null +++ b/spec/services/merge_requests/toggle_attention_requested_service_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::ToggleAttentionRequestedService do + let(:current_user) { create(:user) } + let(:user) { create(:user) } + let(:assignee_user) { create(:user) } + let(:merge_request) { create(:merge_request, reviewers: [user], assignees: [assignee_user]) } + let(:reviewer) { merge_request.find_reviewer(user) } + let(:assignee) { merge_request.find_assignee(assignee_user) } + let(:project) { merge_request.project } + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request, user: user) } + let(:result) { service.execute } + let(:todo_service) { spy('todo service') } + + before do + allow(service).to receive(:todo_service).and_return(todo_service) + + project.add_developer(current_user) + project.add_developer(user) + end + + describe '#execute' do + context 'invalid permissions' do + let(:service) { described_class.new(project: project, current_user: create(:user), merge_request: merge_request, user: user) } + + it 'returns an error' do + expect(result[:status]).to eq :error + end + end + + context 'reviewer does not exist' do + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request, user: create(:user)) } + + it 'returns an error' do + expect(result[:status]).to eq :error + end + end + + context 'reviewer exists' do + before do + reviewer.update!(state: :reviewed) + end + + it 'returns success' do + expect(result[:status]).to eq :success + end + + it 'updates reviewers state' do + service.execute + reviewer.reload + + expect(reviewer.state).to eq 'attention_requested' + end + + it 'creates a new todo for the reviewer' do + expect(todo_service).to receive(:create_attention_requested_todo).with(merge_request, current_user, user) + + service.execute + end + end + + context 'assignee exists' do + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request, user: assignee_user) } + + before do + assignee.update!(state: :reviewed) + end + + it 'returns success' do + expect(result[:status]).to eq :success + end + + it 'updates assignees state' do + service.execute + assignee.reload + + expect(assignee.state).to eq 'attention_requested' + end + + it 'creates a new todo for the reviewer' do + expect(todo_service).to receive(:create_attention_requested_todo).with(merge_request, current_user, assignee_user) + + service.execute + end + end + + context 'assignee is the same as reviewer' do + let(:merge_request) { create(:merge_request, reviewers: [user], assignees: [user]) } + let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request, user: user) } + let(:assignee) { merge_request.find_assignee(user) } + + before do + reviewer.update!(state: :reviewed) + assignee.update!(state: :reviewed) + end + + it 'updates reviewers and assignees state' do + service.execute + reviewer.reload + assignee.reload + + expect(reviewer.state).to eq 'attention_requested' + expect(assignee.state).to eq 'attention_requested' + end + end + + context 'state is attention_requested' do + before do + reviewer.update!(state: :attention_requested) + end + + it 'toggles state to reviewed' do + service.execute + reviewer.reload + + expect(reviewer.state).to eq "reviewed" + end + + it 'does not create a new todo for the reviewer' do + expect(todo_service).not_to receive(:create_attention_requested_todo).with(merge_request, current_user, assignee_user) + + service.execute + end + end + end +end diff --git a/spec/services/namespaces/in_product_marketing_email_records_spec.rb b/spec/services/namespaces/in_product_marketing_email_records_spec.rb new file mode 100644 index 00000000000..e5f1b275f9c --- /dev/null +++ b/spec/services/namespaces/in_product_marketing_email_records_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespaces::InProductMarketingEmailRecords do + let_it_be(:user) { create :user } + + subject(:records) { described_class.new } + + it 'initializes records' do + expect(subject.records).to match_array [] + end + + describe '#save!' do + before do + allow(Users::InProductMarketingEmail).to receive(:bulk_insert!) + + records.add(user, :invite_team, 0) + records.add(user, :create, 1) + end + + it 'bulk inserts added records' do + expect(Users::InProductMarketingEmail).to receive(:bulk_insert!).with(records.records) + records.save! + end + + it 'resets its records' do + records.save! + expect(records.records).to match_array [] + end + end + + describe '#add' do + it 'adds a Users::InProductMarketingEmail record to its records' do + freeze_time do + records.add(user, :invite_team, 0) + records.add(user, :create, 1) + + first, second = records.records + + expect(first).to be_a Users::InProductMarketingEmail + expect(first.track.to_sym).to eq :invite_team + expect(first.series).to eq 0 + expect(first.created_at).to eq Time.zone.now + expect(first.updated_at).to eq Time.zone.now + + expect(second).to be_a Users::InProductMarketingEmail + expect(second.track.to_sym).to eq :create + expect(second.series).to eq 1 + expect(second.created_at).to eq Time.zone.now + expect(second.updated_at).to eq Time.zone.now + end + end + end +end diff --git a/spec/services/namespaces/invite_team_email_service_spec.rb b/spec/services/namespaces/invite_team_email_service_spec.rb new file mode 100644 index 00000000000..60ba91f433d --- /dev/null +++ b/spec/services/namespaces/invite_team_email_service_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespaces::InviteTeamEmailService do + let_it_be(:user) { create(:user, email_opted_in: true) } + + let(:track) { described_class::TRACK } + let(:series) { 0 } + + let(:setup_for_company) { true } + let(:parent_group) { nil } + let(:group) { create(:group, parent: parent_group) } + + subject(:action) { described_class.send_email(user, group) } + + before do + group.add_owner(user) + allow(group).to receive(:setup_for_company).and_return(setup_for_company) + allow(Notify).to receive(:in_product_marketing_email).and_return(double(deliver_later: nil)) + end + + RSpec::Matchers.define :send_invite_team_email do |*args| + match do + expect(Notify).to have_received(:in_product_marketing_email).with(*args).once + end + + match_when_negated do + expect(Notify).not_to have_received(:in_product_marketing_email) + end + end + + shared_examples 'unexperimented' do + it { is_expected.not_to send_invite_team_email } + + it 'does not record sent email' do + expect { subject }.not_to change { Users::InProductMarketingEmail.count } + end + end + + shared_examples 'candidate' do + it { is_expected.to send_invite_team_email(user.id, group.id, track, 0) } + + it 'records sent email' do + expect { subject }.to change { Users::InProductMarketingEmail.count }.by(1) + + expect( + Users::InProductMarketingEmail.where( + user: user, + track: track, + series: 0 + ) + ).to exist + end + + it_behaves_like 'tracks assignment and records the subject', :invite_team_email, :group do + subject { group } + end + end + + context 'when group is in control path' do + before do + stub_experiments(invite_team_email: :control) + end + + it { is_expected.not_to send_invite_team_email } + + it 'does not record sent email' do + expect { subject }.not_to change { Users::InProductMarketingEmail.count } + end + + it_behaves_like 'tracks assignment and records the subject', :invite_team_email, :group do + subject { group } + end + end + + context 'when group is in candidate path' do + before do + stub_experiments(invite_team_email: :candidate) + end + + it_behaves_like 'candidate' + + context 'when the user has not opted into marketing emails' do + let(:user) { create(:user, email_opted_in: false ) } + + it_behaves_like 'unexperimented' + end + + context 'when group is not top level' do + it_behaves_like 'unexperimented' do + let(:parent_group) do + create(:group).tap { |g| g.add_owner(user) } + end + end + end + + context 'when group is not set up for a company' do + it_behaves_like 'unexperimented' do + let(:setup_for_company) { nil } + end + end + + context 'when other users have already been added to the group' do + before do + group.add_developer(create(:user)) + end + + it_behaves_like 'unexperimented' + end + + context 'when other users have already been invited to the group' do + before do + group.add_developer('not_a_user_yet@example.com') + end + + it_behaves_like 'unexperimented' + end + + context 'when the user already got sent the email' do + before do + create(:in_product_marketing_email, user: user, track: track, series: 0) + end + + it_behaves_like 'unexperimented' + end + end +end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 48718cbc24a..fbf5b183365 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -3040,7 +3040,7 @@ RSpec.describe NotificationService, :mailer do it 'emails only the creator' do notification.pipeline_finished(pipeline) - should_only_email(u_custom_notification_enabled, kind: :bcc) + should_only_email(u_custom_notification_enabled) end it_behaves_like 'project emails are disabled' do @@ -3063,7 +3063,7 @@ RSpec.describe NotificationService, :mailer do it 'sends to group notification email' do notification.pipeline_finished(pipeline) - expect(email_recipients(kind: :bcc).first).to eq(group_notification_email) + expect(email_recipients.first).to eq(group_notification_email) end end end @@ -3076,7 +3076,7 @@ RSpec.describe NotificationService, :mailer do it 'emails only the creator' do notification.pipeline_finished(pipeline) - should_only_email(u_member, kind: :bcc) + should_only_email(u_member) end it_behaves_like 'project emails are disabled' do @@ -3098,7 +3098,7 @@ RSpec.describe NotificationService, :mailer do it 'sends to group notification email' do notification.pipeline_finished(pipeline) - expect(email_recipients(kind: :bcc).first).to eq(group_notification_email) + expect(email_recipients.first).to eq(group_notification_email) end end end @@ -3110,7 +3110,7 @@ RSpec.describe NotificationService, :mailer do end it 'emails only the creator' do - should_only_email(u_watcher, kind: :bcc) + should_only_email(u_watcher) end end @@ -3121,7 +3121,7 @@ RSpec.describe NotificationService, :mailer do end it 'emails only the creator' do - should_only_email(u_custom_notification_unset, kind: :bcc) + should_only_email(u_custom_notification_unset) end end @@ -3143,7 +3143,7 @@ RSpec.describe NotificationService, :mailer do end it 'emails only the creator' do - should_only_email(u_custom_notification_enabled, kind: :bcc) + should_only_email(u_custom_notification_enabled) end end @@ -3170,7 +3170,7 @@ RSpec.describe NotificationService, :mailer do it 'emails only the creator' do notification.pipeline_finished(pipeline, ref_status: ref_status) - should_only_email(u_member, kind: :bcc) + should_only_email(u_member) end it_behaves_like 'project emails are disabled' do @@ -3192,7 +3192,7 @@ RSpec.describe NotificationService, :mailer do it 'sends to group notification email' do notification.pipeline_finished(pipeline, ref_status: ref_status) - expect(email_recipients(kind: :bcc).first).to eq(group_notification_email) + expect(email_recipients.first).to eq(group_notification_email) end end end @@ -3204,7 +3204,7 @@ RSpec.describe NotificationService, :mailer do end it 'emails only the creator' do - should_only_email(u_watcher, kind: :bcc) + should_only_email(u_watcher) end end @@ -3215,7 +3215,7 @@ RSpec.describe NotificationService, :mailer do end it 'emails only the creator' do - should_only_email(u_custom_notification_unset, kind: :bcc) + should_only_email(u_custom_notification_unset) end end @@ -3236,7 +3236,7 @@ RSpec.describe NotificationService, :mailer do notification.pipeline_finished(pipeline, ref_status: ref_status) - should_only_email(u_custom_notification_enabled, kind: :bcc) + should_only_email(u_custom_notification_enabled) end end end diff --git a/spec/services/packages/create_dependency_service_spec.rb b/spec/services/packages/create_dependency_service_spec.rb index 261c6b395d5..55414ea68fe 100644 --- a/spec/services/packages/create_dependency_service_spec.rb +++ b/spec/services/packages/create_dependency_service_spec.rb @@ -58,9 +58,9 @@ RSpec.describe Packages::CreateDependencyService do let_it_be(:rows) { [{ name: 'express', version_pattern: '^4.16.4' }] } it 'creates dependences and links' do - original_bulk_insert = ::Gitlab::Database.main.method(:bulk_insert) - expect(::Gitlab::Database.main) - .to receive(:bulk_insert) do |table, rows, return_ids: false, disable_quote: [], on_conflict: nil| + original_bulk_insert = ::ApplicationRecord.method(:legacy_bulk_insert) + expect(::ApplicationRecord) + .to receive(:legacy_bulk_insert) do |table, rows, return_ids: false, disable_quote: [], on_conflict: nil| call_count = table == Packages::Dependency.table_name ? 2 : 1 call_count.times { original_bulk_insert.call(table, rows, return_ids: return_ids, disable_quote: disable_quote, on_conflict: on_conflict) } end.twice diff --git a/spec/services/packages/npm/create_package_service_spec.rb b/spec/services/packages/npm/create_package_service_spec.rb index ba5729eaf59..b1beb2adb3b 100644 --- a/spec/services/packages/npm/create_package_service_spec.rb +++ b/spec/services/packages/npm/create_package_service_spec.rb @@ -16,6 +16,7 @@ RSpec.describe Packages::Npm::CreatePackageService do let(:override) { {} } let(:package_name) { "@#{namespace.path}/my-app" } + let(:version_data) { params.dig('versions', '1.0.1') } subject { described_class.new(project, user, params).execute } @@ -25,6 +26,7 @@ RSpec.describe Packages::Npm::CreatePackageService do .to change { Packages::Package.count }.by(1) .and change { Packages::Package.npm.count }.by(1) .and change { Packages::Tag.count }.by(1) + .and change { Packages::Npm::Metadatum.count }.by(1) end it_behaves_like 'assigns the package creator' do @@ -40,6 +42,8 @@ RSpec.describe Packages::Npm::CreatePackageService do expect(package.version).to eq(version) end + it { expect(subject.npm_metadatum.package_json).to eq(version_data) } + it { expect(subject.name).to eq(package_name) } it { expect(subject.version).to eq(version) } @@ -54,6 +58,48 @@ RSpec.describe Packages::Npm::CreatePackageService do expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1) end end + + context 'with a too large metadata structure' do + before do + params[:versions][version][:test] = 'test' * 10000 + end + + it 'does not create the package' do + expect { subject }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Package json structure is too large') + .and not_change { Packages::Package.count } + .and not_change { Packages::Package.npm.count } + .and not_change { Packages::Tag.count } + .and not_change { Packages::Npm::Metadatum.count } + end + end + + described_class::PACKAGE_JSON_NOT_ALLOWED_FIELDS.each do |field| + context "with not allowed #{field} field" do + before do + params[:versions][version][field] = 'test' + end + + it 'is persisted without the field' do + expect { subject } + .to change { Packages::Package.count }.by(1) + .and change { Packages::Package.npm.count }.by(1) + .and change { Packages::Tag.count }.by(1) + .and change { Packages::Npm::Metadatum.count }.by(1) + expect(subject.npm_metadatum.package_json[field]).to be_blank + end + end + end + + context 'with packages_npm_abbreviated_metadata disabled' do + before do + stub_feature_flags(packages_npm_abbreviated_metadata: false) + end + + it 'creates a package without metadatum' do + expect { subject } + .not_to change { Packages::Npm::Metadatum.count } + end + end end describe '#execute' do diff --git a/spec/services/packages/update_tags_service_spec.rb b/spec/services/packages/update_tags_service_spec.rb index 6e67489fec9..c4256699c94 100644 --- a/spec/services/packages/update_tags_service_spec.rb +++ b/spec/services/packages/update_tags_service_spec.rb @@ -50,7 +50,7 @@ RSpec.describe Packages::UpdateTagsService do it 'is a no op' do expect(package).not_to receive(:tags) - expect(::Gitlab::Database.main).not_to receive(:bulk_insert) + expect(::ApplicationRecord).not_to receive(:legacy_bulk_insert) subject end diff --git a/spec/services/projects/all_issues_count_service_spec.rb b/spec/services/projects/all_issues_count_service_spec.rb new file mode 100644 index 00000000000..d7e35991940 --- /dev/null +++ b/spec/services/projects/all_issues_count_service_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::AllIssuesCountService, :use_clean_rails_memory_store_caching do + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :public, namespace: group) } + let_it_be(:banned_user) { create(:user, :banned) } + + subject { described_class.new(project) } + + it_behaves_like 'a counter caching service' + + describe '#count' do + it 'returns the number of all issues' do + create(:issue, :opened, project: project) + create(:issue, :opened, confidential: true, project: project) + create(:issue, :opened, author: banned_user, project: project) + create(:issue, :closed, project: project) + + expect(subject.count).to eq(4) + end + end +end diff --git a/spec/services/projects/all_merge_requests_count_service_spec.rb b/spec/services/projects/all_merge_requests_count_service_spec.rb new file mode 100644 index 00000000000..13954d688aa --- /dev/null +++ b/spec/services/projects/all_merge_requests_count_service_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::AllMergeRequestsCountService, :use_clean_rails_memory_store_caching do + let_it_be(:project) { create(:project) } + + subject { described_class.new(project) } + + it_behaves_like 'a counter caching service' + + describe '#count' do + it 'returns the number of all merge requests' do + create(:merge_request, + :opened, + source_project: project, + target_project: project) + create(:merge_request, + :closed, + source_project: project, + target_project: project) + create(:merge_request, + :merged, + source_project: project, + target_project: project) + + expect(subject.count).to eq(3) + end + end +end diff --git a/spec/services/projects/container_repository/cache_tags_created_at_service_spec.rb b/spec/services/projects/container_repository/cache_tags_created_at_service_spec.rb deleted file mode 100644 index dfe2ff9e57c..00000000000 --- a/spec/services/projects/container_repository/cache_tags_created_at_service_spec.rb +++ /dev/null @@ -1,133 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ::Projects::ContainerRepository::CacheTagsCreatedAtService, :clean_gitlab_redis_cache do - let_it_be(:dummy_tag_class) { Struct.new(:name, :created_at) } - let_it_be(:repository) { create(:container_repository) } - - let(:tags) { create_tags(5) } - let(:service) { described_class.new(repository) } - - shared_examples 'not interacting with redis' do - it 'does not interact with redis' do - expect(::Gitlab::Redis::Cache).not_to receive(:with) - - subject - end - end - - describe '#populate' do - subject { service.populate(tags) } - - context 'with tags' do - it 'gets values from redis' do - expect(::Gitlab::Redis::Cache).to receive(:with).and_call_original - - expect(subject).to eq(0) - - tags.each { |t| expect(t.created_at).to eq(nil) } - end - - context 'with cached values' do - let(:cached_tags) { tags.first(2) } - - before do - ::Gitlab::Redis::Cache.with do |redis| - cached_tags.each do |tag| - redis.set(cache_key(tag), rfc3339(10.days.ago)) - end - end - end - - it 'gets values from redis' do - expect(::Gitlab::Redis::Cache).to receive(:with).and_call_original - - expect(subject).to eq(2) - - cached_tags.each { |t| expect(t.created_at).not_to eq(nil) } - (tags - cached_tags).each { |t| expect(t.created_at).to eq(nil) } - end - end - end - - context 'with no tags' do - let(:tags) { [] } - - it_behaves_like 'not interacting with redis' - end - end - - describe '#insert' do - let(:max_ttl) { 90.days } - - subject { service.insert(tags, max_ttl) } - - context 'with tags' do - let(:tag) { tags.first } - let(:ttl) { 90.days - 3.days } - - before do - travel_to(Time.zone.local(2021, 9, 2, 12, 0, 0)) - - tag.created_at = DateTime.rfc3339(3.days.ago.rfc3339) - end - - after do - travel_back - end - - it 'inserts values in redis' do - ::Gitlab::Redis::Cache.with do |redis| - expect(redis) - .to receive(:set) - .with(cache_key(tag), rfc3339(tag.created_at), ex: ttl.to_i) - .and_call_original - end - - subject - end - - context 'with some of them already cached' do - let(:tag) { tags.first } - - before do - ::Gitlab::Redis::Cache.with do |redis| - redis.set(cache_key(tag), rfc3339(10.days.ago)) - end - service.populate(tags) - end - - it_behaves_like 'not interacting with redis' - end - end - - context 'with no tags' do - let(:tags) { [] } - - it_behaves_like 'not interacting with redis' - end - - context 'with no expires_in' do - let(:max_ttl) { nil } - - it_behaves_like 'not interacting with redis' - end - end - - def create_tags(size) - Array.new(size) do |i| - dummy_tag_class.new("Tag #{i}", nil) - end - end - - def cache_key(tag) - "container_repository:{#{repository.id}}:tag:#{tag.name}:created_at" - end - - def rfc3339(date_time) - # DateTime rfc3339 is different ActiveSupport::TimeWithZone rfc3339 - # The caching will use DateTime rfc3339 - DateTime.rfc3339(date_time.rfc3339).rfc3339 - end -end diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb index 289bbf4540e..a41ba8216cc 100644 --- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb +++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb @@ -41,322 +41,320 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_ describe '#execute' do subject { service.execute } - shared_examples 'reading and removing tags' do |caching_enabled: true| - context 'when no params are specified' do - let(:params) { {} } + context 'when no params are specified' do + let(:params) { {} } - it 'does not remove anything' do - expect_any_instance_of(Projects::ContainerRepository::DeleteTagsService) - .not_to receive(:execute) - expect_no_caching + it 'does not remove anything' do + expect_any_instance_of(Projects::ContainerRepository::DeleteTagsService) + .not_to receive(:execute) + expect_no_caching - is_expected.to eq(expected_service_response(before_truncate_size: 0, after_truncate_size: 0, before_delete_size: 0)) - end + is_expected.to eq(expected_service_response(before_truncate_size: 0, after_truncate_size: 0, before_delete_size: 0)) end + end - context 'when regex matching everything is specified' do - shared_examples 'removes all matches' do - it 'does remove all tags except latest' do - expect_no_caching + context 'when regex matching everything is specified' do + shared_examples 'removes all matches' do + it 'does remove all tags except latest' do + expect_no_caching - expect_delete(%w(A Ba Bb C D E)) + expect_delete(%w(A Ba Bb C D E)) - is_expected.to eq(expected_service_response(deleted: %w(A Ba Bb C D E))) - end + is_expected.to eq(expected_service_response(deleted: %w(A Ba Bb C D E))) end + end + + let(:params) do + { 'name_regex_delete' => '.*' } + end + it_behaves_like 'removes all matches' + + context 'with deprecated name_regex param' do let(:params) do - { 'name_regex_delete' => '.*' } + { 'name_regex' => '.*' } end it_behaves_like 'removes all matches' + end + end - context 'with deprecated name_regex param' do - let(:params) do - { 'name_regex' => '.*' } - end + context 'with invalid regular expressions' do + shared_examples 'handling an invalid regex' do + it 'keeps all tags' do + expect_no_caching - it_behaves_like 'removes all matches' + expect(Projects::ContainerRepository::DeleteTagsService) + .not_to receive(:new) + + subject end - end - context 'with invalid regular expressions' do - shared_examples 'handling an invalid regex' do - it 'keeps all tags' do - expect_no_caching + it { is_expected.to eq(status: :error, message: 'invalid regex') } - expect(Projects::ContainerRepository::DeleteTagsService) - .not_to receive(:new) + it 'calls error tracking service' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).and_call_original - subject - end + subject + end + end - it { is_expected.to eq(status: :error, message: 'invalid regex') } + context 'when name_regex_delete is invalid' do + let(:params) { { 'name_regex_delete' => '*test*' } } - it 'calls error tracking service' do - expect(Gitlab::ErrorTracking).to receive(:log_exception).and_call_original + it_behaves_like 'handling an invalid regex' + end - subject - end - end + context 'when name_regex is invalid' do + let(:params) { { 'name_regex' => '*test*' } } - context 'when name_regex_delete is invalid' do - let(:params) { { 'name_regex_delete' => '*test*' } } + it_behaves_like 'handling an invalid regex' + end - it_behaves_like 'handling an invalid regex' - end + context 'when name_regex_keep is invalid' do + let(:params) { { 'name_regex_keep' => '*test*' } } - context 'when name_regex is invalid' do - let(:params) { { 'name_regex' => '*test*' } } + it_behaves_like 'handling an invalid regex' + end + end - it_behaves_like 'handling an invalid regex' - end + context 'when delete regex matching specific tags is used' do + let(:params) do + { 'name_regex_delete' => 'C|D' } + end - context 'when name_regex_keep is invalid' do - let(:params) { { 'name_regex_keep' => '*test*' } } + it 'does remove C and D' do + expect_delete(%w(C D)) - it_behaves_like 'handling an invalid regex' - end + expect_no_caching + + is_expected.to eq(expected_service_response(deleted: %w(C D), before_truncate_size: 2, after_truncate_size: 2, before_delete_size: 2)) end - context 'when delete regex matching specific tags is used' do + context 'with overriding allow regex' do let(:params) do - { 'name_regex_delete' => 'C|D' } + { 'name_regex_delete' => 'C|D', + 'name_regex_keep' => 'C' } end - it 'does remove C and D' do - expect_delete(%w(C D)) + it 'does not remove C' do + expect_delete(%w(D)) expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(C D), before_truncate_size: 2, after_truncate_size: 2, before_delete_size: 2)) + is_expected.to eq(expected_service_response(deleted: %w(D), before_truncate_size: 1, after_truncate_size: 1, before_delete_size: 1)) end + end - context 'with overriding allow regex' do - let(:params) do - { 'name_regex_delete' => 'C|D', - 'name_regex_keep' => 'C' } - end + context 'with name_regex_delete overriding deprecated name_regex' do + let(:params) do + { 'name_regex' => 'C|D', + 'name_regex_delete' => 'D' } + end - it 'does not remove C' do - expect_delete(%w(D)) + it 'does not remove C' do + expect_delete(%w(D)) - expect_no_caching + expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(D), before_truncate_size: 1, after_truncate_size: 1, before_delete_size: 1)) - end + is_expected.to eq(expected_service_response(deleted: %w(D), before_truncate_size: 1, after_truncate_size: 1, before_delete_size: 1)) end + end + end - context 'with name_regex_delete overriding deprecated name_regex' do - let(:params) do - { 'name_regex' => 'C|D', - 'name_regex_delete' => 'D' } - end + context 'with allow regex value' do + let(:params) do + { 'name_regex_delete' => '.*', + 'name_regex_keep' => 'B.*' } + end - it 'does not remove C' do - expect_delete(%w(D)) + it 'does not remove B*' do + expect_delete(%w(A C D E)) - expect_no_caching + expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(D), before_truncate_size: 1, after_truncate_size: 1, before_delete_size: 1)) - end - end + is_expected.to eq(expected_service_response(deleted: %w(A C D E), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 4)) + end + end + + context 'when keeping only N tags' do + let(:params) do + { 'name_regex' => 'A|B.*|C', + 'keep_n' => 1 } end - context 'with allow regex value' do - let(:params) do - { 'name_regex_delete' => '.*', - 'name_regex_keep' => 'B.*' } - end + it 'sorts tags by date' do + expect_delete(%w(Bb Ba C)) - it 'does not remove B*' do - expect_delete(%w(A C D E)) + expect_no_caching - expect_no_caching + expect(service).to receive(:order_by_date).and_call_original - is_expected.to eq(expected_service_response(deleted: %w(A C D E), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 4)) - end + is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 3)) end + end - context 'when keeping only N tags' do - let(:params) do - { 'name_regex' => 'A|B.*|C', - 'keep_n' => 1 } - end + context 'when not keeping N tags' do + let(:params) do + { 'name_regex' => 'A|B.*|C' } + end - it 'sorts tags by date' do - expect_delete(%w(Bb Ba C)) + it 'does not sort tags by date' do + expect_delete(%w(A Ba Bb C)) - expect_no_caching + expect_no_caching - expect(service).to receive(:order_by_date).and_call_original + expect(service).not_to receive(:order_by_date) - is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 3)) - end + is_expected.to eq(expected_service_response(deleted: %w(A Ba Bb C), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 4)) end + end - context 'when not keeping N tags' do - let(:params) do - { 'name_regex' => 'A|B.*|C' } - end - - it 'does not sort tags by date' do - expect_delete(%w(A Ba Bb C)) + context 'when removing keeping only 3' do + let(:params) do + { 'name_regex_delete' => '.*', + 'keep_n' => 3 } + end - expect_no_caching + it 'does remove B* and C as they are the oldest' do + expect_delete(%w(Bb Ba C)) - expect(service).not_to receive(:order_by_date) + expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(A Ba Bb C), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 4)) - end + is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) end + end - context 'when removing keeping only 3' do - let(:params) do - { 'name_regex_delete' => '.*', - 'keep_n' => 3 } - end + context 'when removing older than 1 day' do + let(:params) do + { 'name_regex_delete' => '.*', + 'older_than' => '1 day' } + end - it 'does remove B* and C as they are the oldest' do - expect_delete(%w(Bb Ba C)) + it 'does remove B* and C as they are older than 1 day' do + expect_delete(%w(Ba Bb C)) - expect_no_caching + expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) - end + is_expected.to eq(expected_service_response(deleted: %w(Ba Bb C), before_delete_size: 3)) end + end - context 'when removing older than 1 day' do - let(:params) do - { 'name_regex_delete' => '.*', - 'older_than' => '1 day' } - end + context 'when combining all parameters' do + let(:params) do + { 'name_regex_delete' => '.*', + 'keep_n' => 1, + 'older_than' => '1 day' } + end - it 'does remove B* and C as they are older than 1 day' do - expect_delete(%w(Ba Bb C)) + it 'does remove B* and C' do + expect_delete(%w(Bb Ba C)) - expect_no_caching + expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(Ba Bb C), before_delete_size: 3)) - end + is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) end + end + + context 'when running a container_expiration_policy' do + let(:user) { nil } - context 'when combining all parameters' do + context 'with valid container_expiration_policy param' do let(:params) do { 'name_regex_delete' => '.*', 'keep_n' => 1, - 'older_than' => '1 day' } + 'older_than' => '1 day', + 'container_expiration_policy' => true } end - it 'does remove B* and C' do - expect_delete(%w(Bb Ba C)) + it 'succeeds without a user' do + expect_delete(%w(Bb Ba C), container_expiration_policy: true) - expect_no_caching + expect_caching is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) end end - context 'when running a container_expiration_policy' do - let(:user) { nil } - - context 'with valid container_expiration_policy param' do - let(:params) do - { 'name_regex_delete' => '.*', - 'keep_n' => 1, - 'older_than' => '1 day', - 'container_expiration_policy' => true } - end - - it 'succeeds without a user' do - expect_delete(%w(Bb Ba C), container_expiration_policy: true) - - caching_enabled ? expect_caching : expect_no_caching - - is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) - end + context 'without container_expiration_policy param' do + let(:params) do + { 'name_regex_delete' => '.*', + 'keep_n' => 1, + 'older_than' => '1 day' } end - context 'without container_expiration_policy param' do - let(:params) do - { 'name_regex_delete' => '.*', - 'keep_n' => 1, - 'older_than' => '1 day' } - end - - it 'fails' do - is_expected.to eq(status: :error, message: 'access denied') - end + it 'fails' do + is_expected.to eq(status: :error, message: 'access denied') end end + end - context 'truncating the tags list' do - let(:params) do - { - 'name_regex_delete' => '.*', - 'keep_n' => 1 - } - end + context 'truncating the tags list' do + let(:params) do + { + 'name_regex_delete' => '.*', + 'keep_n' => 1 + } + end - shared_examples 'returning the response' do |status:, original_size:, before_truncate_size:, after_truncate_size:, before_delete_size:| - it 'returns the response' do - expect_no_caching + shared_examples 'returning the response' do |status:, original_size:, before_truncate_size:, after_truncate_size:, before_delete_size:| + it 'returns the response' do + expect_no_caching - result = subject + result = subject - service_response = expected_service_response( - status: status, - original_size: original_size, - before_truncate_size: before_truncate_size, - after_truncate_size: after_truncate_size, - before_delete_size: before_delete_size, - deleted: nil - ) + service_response = expected_service_response( + status: status, + original_size: original_size, + before_truncate_size: before_truncate_size, + after_truncate_size: after_truncate_size, + before_delete_size: before_delete_size, + deleted: nil + ) - expect(result).to eq(service_response) - end + expect(result).to eq(service_response) end + end - where(:feature_flag_enabled, :max_list_size, :delete_tags_service_status, :expected_status, :expected_truncated) do - false | 10 | :success | :success | false - false | 10 | :error | :error | false - false | 3 | :success | :success | false - false | 3 | :error | :error | false - false | 0 | :success | :success | false - false | 0 | :error | :error | false - true | 10 | :success | :success | false - true | 10 | :error | :error | false - true | 3 | :success | :error | true - true | 3 | :error | :error | true - true | 0 | :success | :success | false - true | 0 | :error | :error | false - end + where(:feature_flag_enabled, :max_list_size, :delete_tags_service_status, :expected_status, :expected_truncated) do + false | 10 | :success | :success | false + false | 10 | :error | :error | false + false | 3 | :success | :success | false + false | 3 | :error | :error | false + false | 0 | :success | :success | false + false | 0 | :error | :error | false + true | 10 | :success | :success | false + true | 10 | :error | :error | false + true | 3 | :success | :error | true + true | 3 | :error | :error | true + true | 0 | :success | :success | false + true | 0 | :error | :error | false + end - with_them do - before do - stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled) - stub_application_setting(container_registry_cleanup_tags_service_max_list_size: max_list_size) - allow_next_instance_of(Projects::ContainerRepository::DeleteTagsService) do |service| - expect(service).to receive(:execute).and_return(status: delete_tags_service_status) - end + with_them do + before do + stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled) + stub_application_setting(container_registry_cleanup_tags_service_max_list_size: max_list_size) + allow_next_instance_of(Projects::ContainerRepository::DeleteTagsService) do |service| + expect(service).to receive(:execute).and_return(status: delete_tags_service_status) end + end - original_size = 7 - keep_n = 1 + original_size = 7 + keep_n = 1 - it_behaves_like( - 'returning the response', - status: params[:expected_status], - original_size: original_size, - before_truncate_size: original_size - keep_n, - after_truncate_size: params[:expected_truncated] ? params[:max_list_size] + keep_n : original_size - keep_n, - before_delete_size: params[:expected_truncated] ? params[:max_list_size] : original_size - keep_n - 1 # one tag is filtered out with older_than filter - ) - end + it_behaves_like( + 'returning the response', + status: params[:expected_status], + original_size: original_size, + before_truncate_size: original_size - keep_n, + after_truncate_size: params[:expected_truncated] ? params[:max_list_size] + keep_n : original_size - keep_n, + before_delete_size: params[:expected_truncated] ? params[:max_list_size] : original_size - keep_n - 1 # one tag is filtered out with older_than filter + ) end end - context 'caching' do + context 'caching', :freeze_time do let(:params) do { 'name_regex_delete' => '.*', @@ -381,17 +379,12 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_ before do expect_delete(%w(Bb Ba C), container_expiration_policy: true) - travel_to(Time.zone.local(2021, 9, 2, 12, 0, 0)) # We froze time so we need to set the created_at stubs again stub_digest_config('sha256:configA', 1.hour.ago) stub_digest_config('sha256:configB', 5.days.ago) stub_digest_config('sha256:configC', 1.month.ago) end - after do - travel_back - end - it 'caches the created_at values' do ::Gitlab::Redis::Cache.with do |redis| expect_mget(redis, tags_and_created_ats.keys) @@ -450,32 +443,6 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_ DateTime.rfc3339(date_time.rfc3339).rfc3339 end end - - context 'with container_registry_expiration_policies_caching enabled for the project' do - before do - stub_feature_flags(container_registry_expiration_policies_caching: project) - end - - it_behaves_like 'reading and removing tags', caching_enabled: true - end - - context 'with container_registry_expiration_policies_caching disabled' do - before do - stub_feature_flags(container_registry_expiration_policies_caching: false) - end - - it_behaves_like 'reading and removing tags', caching_enabled: false - end - - context 'with container_registry_expiration_policies_caching not enabled for the project' do - let_it_be(:another_project) { create(:project) } - - before do - stub_feature_flags(container_registry_expiration_policies_caching: another_project) - end - - it_behaves_like 'reading and removing tags', caching_enabled: false - end end private diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index d7c43ac676e..2aa9be5066f 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -49,6 +49,7 @@ RSpec.describe Projects::CreateService, '#execute' do it 'keeps them as specified' do expect(project.name).to eq('one') expect(project.path).to eq('two') + expect(project.project_namespace).to be_in_sync_with_project(project) end end @@ -58,6 +59,7 @@ RSpec.describe Projects::CreateService, '#execute' do it 'sets name == path' do expect(project.path).to eq('one.two_three-four') expect(project.name).to eq(project.path) + expect(project.project_namespace).to be_in_sync_with_project(project) end end @@ -67,6 +69,7 @@ RSpec.describe Projects::CreateService, '#execute' do it 'sets path == name' do expect(project.name).to eq('one.two_three-four') expect(project.path).to eq(project.name) + expect(project.project_namespace).to be_in_sync_with_project(project) end end @@ -78,6 +81,7 @@ RSpec.describe Projects::CreateService, '#execute' do it 'parameterizes the name' do expect(project.name).to eq('one.two_three-four and five') expect(project.path).to eq('one-two_three-four-and-five') + expect(project.project_namespace).to be_in_sync_with_project(project) end end end @@ -111,13 +115,14 @@ RSpec.describe Projects::CreateService, '#execute' do end context 'user namespace' do - it do + it 'creates a project in user namespace' do project = create_project(user, opts) expect(project).to be_valid expect(project.owner).to eq(user) expect(project.team.maintainers).to include(user) expect(project.namespace).to eq(user.namespace) + expect(project.project_namespace).to be_in_sync_with_project(project) end end @@ -151,6 +156,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project.owner).to eq(user) expect(project.team.maintainers).to contain_exactly(user) expect(project.namespace).to eq(user.namespace) + expect(project.project_namespace).to be_in_sync_with_project(project) end end @@ -160,6 +166,7 @@ RSpec.describe Projects::CreateService, '#execute' do project = create_project(admin, opts) expect(project).not_to be_persisted + expect(project.project_namespace).to be_in_sync_with_project(project) end end end @@ -183,6 +190,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project.namespace).to eq(group) expect(project.team.owners).to include(user) expect(user.authorized_projects).to include(project) + expect(project.project_namespace).to be_in_sync_with_project(project) end end @@ -339,6 +347,7 @@ RSpec.describe Projects::CreateService, '#execute' do end imported_project + expect(imported_project.project_namespace).to be_in_sync_with_project(imported_project) end it 'stores import data and URL' do @@ -406,6 +415,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project.visibility_level).to eq(project_level) expect(project).to be_saved expect(project).to be_valid + expect(project.project_namespace).to be_in_sync_with_project(project) end end end @@ -424,6 +434,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project.errors.messages[:visibility_level].first).to( match('restricted by your GitLab administrator') ) + expect(project.project_namespace).to be_in_sync_with_project(project) end it 'does not allow a restricted visibility level for admins when admin mode is disabled' do @@ -493,6 +504,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project).to be_valid expect(project.owner).to eq(user) expect(project.namespace).to eq(user.namespace) + expect(project.project_namespace).to be_in_sync_with_project(project) end context 'when another repository already exists on disk' do @@ -522,6 +534,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project).to respond_to(:errors) expect(project.errors.messages).to have_key(:base) expect(project.errors.messages[:base].first).to match('There is already a repository with that name on disk') + expect(project.project_namespace).to be_in_sync_with_project(project) end it 'does not allow to import project when path matches existing repository on disk' do @@ -531,6 +544,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project).to respond_to(:errors) expect(project.errors.messages).to have_key(:base) expect(project.errors.messages[:base].first).to match('There is already a repository with that name on disk') + expect(project.project_namespace).to be_in_sync_with_project(project) end end @@ -555,6 +569,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project).to respond_to(:errors) expect(project.errors.messages).to have_key(:base) expect(project.errors.messages[:base].first).to match('There is already a repository with that name on disk') + expect(project.project_namespace).to be_in_sync_with_project(project) end end end @@ -651,7 +666,7 @@ RSpec.describe Projects::CreateService, '#execute' do end context 'with an active group-level integration' do - let!(:group_integration) { create(:prometheus_integration, group: group, project: nil, api_url: 'https://prometheus.group.com/') } + let!(:group_integration) { create(:prometheus_integration, :group, group: group, api_url: 'https://prometheus.group.com/') } let!(:group) do create(:group).tap do |group| group.add_owner(user) @@ -672,7 +687,7 @@ RSpec.describe Projects::CreateService, '#execute' do end context 'with an active subgroup' do - let!(:subgroup_integration) { create(:prometheus_integration, group: subgroup, project: nil, api_url: 'https://prometheus.subgroup.com/') } + let!(:subgroup_integration) { create(:prometheus_integration, :group, group: subgroup, api_url: 'https://prometheus.subgroup.com/') } let!(:subgroup) do create(:group, parent: group).tap do |subgroup| subgroup.add_owner(user) @@ -810,11 +825,11 @@ RSpec.describe Projects::CreateService, '#execute' do ).to be_truthy end - it 'schedules authorization update for users with access to group' do + it 'schedules authorization update for users with access to group', :sidekiq_inline do expect(AuthorizedProjectsWorker).not_to( receive(:bulk_perform_async) ) - expect(AuthorizedProjectUpdate::ProjectCreateWorker).to( + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to( receive(:perform_async).and_call_original ) expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( @@ -825,7 +840,11 @@ RSpec.describe Projects::CreateService, '#execute' do .and_call_original ) - create_project(user, opts) + project = create_project(user, opts) + + expect( + Ability.allowed?(other_user, :developer_access, project) + ).to be_truthy end end @@ -866,6 +885,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project).to be_valid expect(project.shared_runners_enabled).to eq(expected_result_for_project) + expect(project.project_namespace).to be_in_sync_with_project(project) end end end @@ -886,6 +906,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project).to be_valid expect(project.shared_runners_enabled).to eq(expected_result_for_project) + expect(project.project_namespace).to be_in_sync_with_project(project) end end end @@ -903,6 +924,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project.persisted?).to eq(false) expect(project).to be_invalid expect(project.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group does not allow it') + expect(project.project_namespace).to be_in_sync_with_project(project) end end end @@ -922,6 +944,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project).to be_valid expect(project.shared_runners_enabled).to eq(expected_result) + expect(project.project_namespace).to be_in_sync_with_project(project) end end end diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index 9bdd9800fcc..ac84614121a 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -331,6 +331,14 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do end end end + + context 'for an archived project' do + before do + project.update!(archived: true) + end + + it_behaves_like 'deleting the project with pipeline and build' + end end describe 'container registry' do diff --git a/spec/services/projects/import_export/export_service_spec.rb b/spec/services/projects/import_export/export_service_spec.rb index 111c1264777..6002aaf427a 100644 --- a/spec/services/projects/import_export/export_service_spec.rb +++ b/spec/services/projects/import_export/export_service_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' RSpec.describe Projects::ImportExport::ExportService do describe '#execute' do - let!(:user) { create(:user) } + let_it_be(:user) { create(:user) } + let(:project) { create(:project) } let(:shared) { project.import_export_shared } let!(:after_export_strategy) { Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy.new } @@ -28,7 +29,14 @@ RSpec.describe Projects::ImportExport::ExportService do end it 'saves the models' do - expect(Gitlab::ImportExport::Project::TreeSaver).to receive(:new).and_call_original + saver_params = { + project: project, + current_user: user, + shared: shared, + params: {}, + logger: an_instance_of(Gitlab::Export::Logger) + } + expect(Gitlab::ImportExport::Project::TreeSaver).to receive(:new).with(saver_params).and_call_original service.execute end diff --git a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb index f9ff959fa05..04c6349bf52 100644 --- a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb @@ -102,6 +102,7 @@ RSpec.describe Projects::LfsPointers::LfsDownloadService do it 'skips read_total_timeout', :aggregate_failures do stub_const('GitLab::HTTP::DEFAULT_READ_TOTAL_TIMEOUT', 0) + expect(ProjectCacheWorker).to receive(:perform_async).once expect(Gitlab::Metrics::System).not_to receive(:monotonic_time) expect(subject.execute).to include(status: :success) end diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index eab7228307a..61edfd23700 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -207,13 +207,5 @@ RSpec.describe Projects::ParticipantsService do end it_behaves_like 'return project members' - - context 'when feature flag :linear_participants_service_ancestor_scopes is disabled' do - before do - stub_feature_flags(linear_participants_service_ancestor_scopes: false) - end - - it_behaves_like 'return project members' - end end end diff --git a/spec/services/projects/prometheus/alerts/notify_service_spec.rb b/spec/services/projects/prometheus/alerts/notify_service_spec.rb index 25cf588dedf..3bd96ad19bc 100644 --- a/spec/services/projects/prometheus/alerts/notify_service_spec.rb +++ b/spec/services/projects/prometheus/alerts/notify_service_spec.rb @@ -218,8 +218,7 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do .to receive(:new) .with(project, kind_of(Hash)) .exactly(3).times - .and_return(process_service) - expect(process_service).to receive(:execute).exactly(3).times + .and_call_original subject end diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index b539b01066e..c47d44002cc 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Projects::TransferService do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } - let_it_be(:group_integration) { create(:integrations_slack, group: group, project: nil, webhook: 'http://group.slack.com') } + let_it_be(:group_integration) { create(:integrations_slack, :group, group: group, webhook: 'http://group.slack.com') } let(:project) { create(:project, :repository, :legacy_storage, namespace: user.namespace) } @@ -66,8 +66,6 @@ RSpec.describe Projects::TransferService do end context 'when project has an associated project namespace' do - let!(:project_namespace) { create(:project_namespace, project: project) } - it 'keeps project namespace in sync with project' do transfer_result = execute_transfer @@ -272,8 +270,6 @@ RSpec.describe Projects::TransferService do end context 'when project has an associated project namespace' do - let!(:project_namespace) { create(:project_namespace, project: project) } - it 'keeps project namespace in sync with project' do attempt_project_transfer @@ -294,8 +290,6 @@ RSpec.describe Projects::TransferService do end context 'when project has an associated project namespace' do - let!(:project_namespace) { create(:project_namespace, project: project) } - it 'keeps project namespace in sync with project' do transfer_result = execute_transfer diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index d67b189f90e..611261cd92c 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -1326,14 +1326,25 @@ RSpec.describe QuickActions::InterpretService do let(:issuable) { issue } end - it_behaves_like 'confidential command' do - let(:content) { '/confidential' } - let(:issuable) { issue } - end + context '/confidential' do + it_behaves_like 'confidential command' do + let(:content) { '/confidential' } + let(:issuable) { issue } + end - it_behaves_like 'confidential command' do - let(:content) { '/confidential' } - let(:issuable) { create(:incident, project: project) } + it_behaves_like 'confidential command' do + let(:content) { '/confidential' } + let(:issuable) { create(:incident, project: project) } + end + + context 'when non-member is creating a new issue' do + let(:service) { described_class.new(project, create(:user)) } + + it_behaves_like 'confidential command' do + let(:content) { '/confidential' } + let(:issuable) { build(:issue, project: project) } + end + end end it_behaves_like 'lock command' do @@ -2542,4 +2553,32 @@ RSpec.describe QuickActions::InterpretService do end end end + + describe '#available_commands' do + context 'when Guest is creating a new issue' do + let_it_be(:guest) { create(:user) } + + let(:issue) { build(:issue, project: public_project) } + let(:service) { described_class.new(project, guest) } + + before_all do + public_project.add_guest(guest) + end + + it 'includes commands to set metadata' do + # milestone action is only available when project has a milestone + milestone + + available_commands = service.available_commands(issue) + + expect(available_commands).to include( + a_hash_including(name: :label), + a_hash_including(name: :milestone), + a_hash_including(name: :copy_metadata), + a_hash_including(name: :assign), + a_hash_including(name: :due) + ) + end + end + end end diff --git a/spec/services/resource_events/change_labels_service_spec.rb b/spec/services/resource_events/change_labels_service_spec.rb index b987e3204ad..c2c0a4c2126 100644 --- a/spec/services/resource_events/change_labels_service_spec.rb +++ b/spec/services/resource_events/change_labels_service_spec.rb @@ -54,7 +54,7 @@ RSpec.describe ResourceEvents::ChangeLabelsService do let(:removed) { [labels[1]] } it 'creates all label events in a single query' do - expect(Gitlab::Database.main).to receive(:bulk_insert).once.and_call_original + expect(ApplicationRecord).to receive(:legacy_bulk_insert).once.and_call_original expect { subject }.to change { resource.resource_label_events.count }.from(0).to(2) end end diff --git a/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb b/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb index cb42ad5b617..71b1d0993ee 100644 --- a/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb +++ b/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb @@ -4,18 +4,20 @@ require 'spec_helper' RSpec.describe ResourceEvents::SyntheticLabelNotesBuilderService do describe '#execute' do - let!(:user) { create(:user) } + let_it_be(:user) { create(:user) } - let!(:issue) { create(:issue, author: user) } + let_it_be(:issue) { create(:issue, author: user) } - let!(:event1) { create(:resource_label_event, issue: issue) } - let!(:event2) { create(:resource_label_event, issue: issue) } - let!(:event3) { create(:resource_label_event, issue: issue) } + let_it_be(:event1) { create(:resource_label_event, issue: issue) } + let_it_be(:event2) { create(:resource_label_event, issue: issue) } + let_it_be(:event3) { create(:resource_label_event, issue: issue) } it 'returns the expected synthetic notes' do notes = ResourceEvents::SyntheticLabelNotesBuilderService.new(issue, user).execute expect(notes.size).to eq(3) end + + it_behaves_like 'filters by paginated notes', :resource_label_event end end diff --git a/spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb b/spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb index 1b35e224e98..9c6b6a33b57 100644 --- a/spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb +++ b/spec/services/resource_events/synthetic_milestone_notes_builder_service_spec.rb @@ -24,5 +24,7 @@ RSpec.describe ResourceEvents::SyntheticMilestoneNotesBuilderService do 'removed milestone' ]) end + + it_behaves_like 'filters by paginated notes', :resource_milestone_event end end diff --git a/spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb b/spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb new file mode 100644 index 00000000000..79500f3768b --- /dev/null +++ b/spec/services/resource_events/synthetic_state_notes_builder_service_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceEvents::SyntheticStateNotesBuilderService do + describe '#execute' do + let_it_be(:user) { create(:user) } + + it_behaves_like 'filters by paginated notes', :resource_state_event + end +end diff --git a/spec/services/security/ci_configuration/sast_iac_create_service_spec.rb b/spec/services/security/ci_configuration/sast_iac_create_service_spec.rb new file mode 100644 index 00000000000..deb10732b37 --- /dev/null +++ b/spec/services/security/ci_configuration/sast_iac_create_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::CiConfiguration::SastIacCreateService, :snowplow do + subject(:result) { described_class.new(project, user).execute } + + let(:branch_name) { 'set-sast-iac-config-1' } + + let(:snowplow_event) do + { + category: 'Security::CiConfiguration::SastIacCreateService', + action: 'create', + label: '' + } + end + + include_examples 'services security ci configuration create service', true +end diff --git a/spec/services/spam/spam_verdict_service_spec.rb b/spec/services/spam/spam_verdict_service_spec.rb index 659c21b7d4f..99047f3233b 100644 --- a/spec/services/spam/spam_verdict_service_spec.rb +++ b/spec/services/spam/spam_verdict_service_spec.rb @@ -267,8 +267,8 @@ RSpec.describe Spam::SpamVerdictService do where(:verdict_value, :expected) do ::Spam::SpamConstants::ALLOW | ::Spam::SpamConstants::ALLOW ::Spam::SpamConstants::CONDITIONAL_ALLOW | ::Spam::SpamConstants::CONDITIONAL_ALLOW - ::Spam::SpamConstants::DISALLOW | ::Spam::SpamConstants::CONDITIONAL_ALLOW - ::Spam::SpamConstants::BLOCK_USER | ::Spam::SpamConstants::CONDITIONAL_ALLOW + ::Spam::SpamConstants::DISALLOW | ::Spam::SpamConstants::DISALLOW + ::Spam::SpamConstants::BLOCK_USER | ::Spam::SpamConstants::BLOCK_USER end # rubocop: enable Lint/BinaryOperatorWithIdenticalOperands diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 1a421999ffb..ce0122ae301 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -348,193 +348,6 @@ RSpec.describe SystemNoteService do end end - describe 'Jira integration' do - include JiraServiceHelper - - let(:project) { create(:jira_project, :repository) } - let(:author) { create(:user) } - let(:issue) { create(:issue, project: project) } - let(:merge_request) { create(:merge_request, :simple, target_project: project, source_project: project) } - let(:jira_issue) { ExternalIssue.new("JIRA-1", project)} - let(:jira_tracker) { project.jira_integration } - let(:commit) { project.commit } - let(:comment_url) { jira_api_comment_url(jira_issue.id) } - let(:success_message) { "SUCCESS: Successfully posted to http://jira.example.net." } - - before do - stub_jira_integration_test - stub_jira_urls(jira_issue.id) - jira_integration_settings - end - - def cross_reference(type, link_exists = false) - noteable = type == 'commit' ? commit : merge_request - - links = [] - if link_exists - url = if type == 'commit' - "#{Settings.gitlab.base_url}/#{project.namespace.path}/#{project.path}/-/commit/#{commit.id}" - else - "#{Settings.gitlab.base_url}/#{project.namespace.path}/#{project.path}/-/merge_requests/#{merge_request.iid}" - end - - link = double(object: { 'url' => url }) - links << link - expect(link).to receive(:save!) - end - - allow(JIRA::Resource::Remotelink).to receive(:all).and_return(links) - - described_class.cross_reference(jira_issue, noteable, author) - end - - noteable_types = %w(merge_requests commit) - - noteable_types.each do |type| - context "when noteable is a #{type}" do - it "blocks cross reference when #{type.underscore}_events is false" do - jira_tracker.update!("#{type}_events" => false) - - expect(cross_reference(type)).to eq(s_('JiraService|Events for %{noteable_model_name} are disabled.') % { noteable_model_name: type.pluralize.humanize.downcase }) - end - - it "creates cross reference when #{type.underscore}_events is true" do - jira_tracker.update!("#{type}_events" => true) - - expect(cross_reference(type)).to eq(success_message) - end - end - - context 'when a new cross reference is created' do - it 'creates a new comment and remote link' do - cross_reference(type) - - expect(WebMock).to have_requested(:post, jira_api_comment_url(jira_issue)) - expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)) - end - end - - context 'when a link exists' do - it 'updates a link but does not create a new comment' do - expect(WebMock).not_to have_requested(:post, jira_api_comment_url(jira_issue)) - - cross_reference(type, true) - end - end - end - - describe "new reference" do - let(:favicon_path) { "http://localhost/assets/#{find_asset('favicon.png').digest_path}" } - - before do - allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) - end - - context 'for commits' do - it "creates comment" do - result = described_class.cross_reference(jira_issue, commit, author) - - expect(result).to eq(success_message) - end - - it "creates remote link" do - described_class.cross_reference(jira_issue, commit, author) - - expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with( - body: hash_including( - GlobalID: "GitLab", - relationship: 'mentioned on', - object: { - url: project_commit_url(project, commit), - title: "Commit - #{commit.title}", - icon: { title: "GitLab", url16x16: favicon_path }, - status: { resolved: false } - } - ) - ).once - end - end - - context 'for issues' do - let(:issue) { create(:issue, project: project) } - - it "creates comment" do - result = described_class.cross_reference(jira_issue, issue, author) - - expect(result).to eq(success_message) - end - - it "creates remote link" do - described_class.cross_reference(jira_issue, issue, author) - - expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with( - body: hash_including( - GlobalID: "GitLab", - relationship: 'mentioned on', - object: { - url: project_issue_url(project, issue), - title: "Issue - #{issue.title}", - icon: { title: "GitLab", url16x16: favicon_path }, - status: { resolved: false } - } - ) - ).once - end - end - - context 'for snippets' do - let(:snippet) { create(:snippet, project: project) } - - it "creates comment" do - result = described_class.cross_reference(jira_issue, snippet, author) - - expect(result).to eq(success_message) - end - - it "creates remote link" do - described_class.cross_reference(jira_issue, snippet, author) - - expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with( - body: hash_including( - GlobalID: "GitLab", - relationship: 'mentioned on', - object: { - url: project_snippet_url(project, snippet), - title: "Snippet - #{snippet.title}", - icon: { title: "GitLab", url16x16: favicon_path }, - status: { resolved: false } - } - ) - ).once - end - end - end - - describe "existing reference" do - before do - allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) - message = double('message') - allow(message).to receive(:include?) { true } - allow_next_instance_of(JIRA::Resource::Issue) do |instance| - allow(instance).to receive(:comments).and_return([OpenStruct.new(body: message)]) - end - end - - it "does not return success message" do - result = described_class.cross_reference(jira_issue, commit, author) - - expect(result).not_to eq(success_message) - end - - it 'does not try to create comment and remote link' do - subject - - expect(WebMock).not_to have_requested(:post, jira_api_comment_url(jira_issue)) - expect(WebMock).not_to have_requested(:post, jira_api_remote_link_url(jira_issue)) - end - end - end - describe '.change_time_estimate' do it 'calls TimeTrackingService' do expect_next_instance_of(::SystemNotes::TimeTrackingService) do |service| @@ -781,6 +594,18 @@ RSpec.describe SystemNoteService do end end + describe '.resolve_incident_status' do + let(:incident) { build(:incident, :closed) } + + it 'calls IncidentService' do + expect_next_instance_of(SystemNotes::IncidentService) do |service| + expect(service).to receive(:resolve_incident_status) + end + + described_class.resolve_incident_status(incident, author) + end + end + describe '.log_resolving_alert' do let(:alert) { build(:alert_management_alert) } let(:monitoring_tool) { 'Prometheus' } diff --git a/spec/services/system_notes/incident_service_spec.rb b/spec/services/system_notes/incident_service_spec.rb index ab9b9eb2bd4..669e357b7a4 100644 --- a/spec/services/system_notes/incident_service_spec.rb +++ b/spec/services/system_notes/incident_service_spec.rb @@ -56,4 +56,14 @@ RSpec.describe ::SystemNotes::IncidentService do end end end + + describe '#resolve_incident_status' do + subject(:resolve_incident_status) { described_class.new(noteable: noteable, project: project, author: author).resolve_incident_status } + + it 'creates a new note about resolved incident', :aggregate_failures do + expect { resolve_incident_status }.to change { noteable.notes.count }.by(1) + + expect(noteable.notes.last.note).to eq('changed the status to **Resolved** by closing the incident') + end + end end diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb index 71a28a89cd8..fd481aa6ddb 100644 --- a/spec/services/system_notes/issuables_service_spec.rb +++ b/spec/services/system_notes/issuables_service_spec.rb @@ -347,6 +347,23 @@ RSpec.describe ::SystemNotes::IssuablesService do end end end + + context 'with external issue' do + let(:noteable) { ExternalIssue.new('JIRA-123', project) } + let(:mentioner) { project.commit } + + it 'queues a background worker' do + expect(Integrations::CreateExternalCrossReferenceWorker).to receive(:perform_async).with( + project.id, + 'JIRA-123', + 'Commit', + mentioner.id, + author.id + ) + + subject + end + end end end diff --git a/spec/services/tasks_to_be_done/base_service_spec.rb b/spec/services/tasks_to_be_done/base_service_spec.rb new file mode 100644 index 00000000000..bf6be6d46e5 --- /dev/null +++ b/spec/services/tasks_to_be_done/base_service_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TasksToBeDone::BaseService do + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:assignee_one) { create(:user) } + let_it_be(:assignee_two) { create(:user) } + let_it_be(:assignee_ids) { [assignee_one.id] } + let_it_be(:label) { create(:label, title: 'tasks to be done:ci', project: project) } + + before do + project.add_maintainer(current_user) + project.add_developer(assignee_one) + project.add_developer(assignee_two) + end + + subject(:service) do + TasksToBeDone::CreateCiTaskService.new( + project: project, + current_user: current_user, + assignee_ids: assignee_ids + ) + end + + context 'no existing task issue', :aggregate_failures do + it 'creates an issue' do + params = { + assignee_ids: assignee_ids, + title: 'Set up CI/CD', + description: anything, + add_labels: label.title + } + + expect(Issues::BuildService) + .to receive(:new) + .with(project: project, current_user: current_user, params: params) + .and_call_original + + expect { service.execute }.to change(Issue, :count).by(1) + + expect(project.issues.last).to have_attributes( + author: current_user, + title: params[:title], + assignees: [assignee_one], + labels: [label] + ) + end + end + + context 'an open issue with the same label already exists', :aggregate_failures do + let_it_be(:assignee_ids) { [assignee_two.id] } + + it 'assigns the user to the existing issue' do + issue = create(:labeled_issue, project: project, labels: [label], assignees: [assignee_one]) + params = { add_assignee_ids: assignee_ids } + + expect(Issues::UpdateService) + .to receive(:new) + .with(project: project, current_user: current_user, params: params) + .and_call_original + + expect { service.execute }.not_to change(Issue, :count) + + expect(issue.reload.assignees).to match_array([assignee_one, assignee_two]) + end + end +end diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 6a8e6dc8970..7103cb0b66a 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -1218,6 +1218,17 @@ RSpec.describe TodoService do end end + describe '#create_attention_requested_todo' do + let(:target) { create(:merge_request, author: author, source_project: project) } + let(:user) { create(:user) } + + it 'creates a todo for user' do + service.create_attention_requested_todo(target, author, user) + + should_create_todo(user: user, target: target, action: Todo::ATTENTION_REQUESTED) + end + end + def should_create_todo(attributes = {}) attributes.reverse_merge!( project: project, diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb index 3244db4c1fb..52c7b54ed72 100644 --- a/spec/services/users/update_service_spec.rb +++ b/spec/services/users/update_service_spec.rb @@ -53,7 +53,7 @@ RSpec.describe Users::UpdateService do result = update_user(user, status: { emoji: "Moo!" }) expect(result[:status]).to eq(:error) - expect(result[:message]).to eq("Emoji is not included in the list") + expect(result[:message]).to eq("Emoji is not a valid emoji name") end it 'updates user detail with provided attributes' do diff --git a/spec/services/users/upsert_credit_card_validation_service_spec.rb b/spec/services/users/upsert_credit_card_validation_service_spec.rb index bede30e1898..952d482f1bd 100644 --- a/spec/services/users/upsert_credit_card_validation_service_spec.rb +++ b/spec/services/users/upsert_credit_card_validation_service_spec.rb @@ -15,6 +15,7 @@ RSpec.describe Users::UpsertCreditCardValidationService do credit_card_expiration_year: expiration_year, credit_card_expiration_month: 1, credit_card_holder_name: 'John Smith', + credit_card_type: 'AmericanExpress', credit_card_mask_number: '1111' } end @@ -30,7 +31,16 @@ RSpec.describe Users::UpsertCreditCardValidationService do result = service.execute expect(result.status).to eq(:success) - expect(user.reload.credit_card_validated_at).to eq(credit_card_validated_time) + + user.reload + + expect(user.credit_card_validation).to have_attributes( + credit_card_validated_at: credit_card_validated_time, + network: 'AmericanExpress', + holder_name: 'John Smith', + last_digits: 1111, + expiration_date: Date.new(expiration_year, 1, 31) + ) end end @@ -97,6 +107,7 @@ RSpec.describe Users::UpsertCreditCardValidationService do expiration_date: Date.new(expiration_year, 1, 31), holder_name: "John Smith", last_digits: 1111, + network: "AmericanExpress", user_id: user_id } diff --git a/spec/sidekiq_cluster/sidekiq_cluster_spec.rb b/spec/sidekiq_cluster/sidekiq_cluster_spec.rb new file mode 100644 index 00000000000..1d2b47e78ce --- /dev/null +++ b/spec/sidekiq_cluster/sidekiq_cluster_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require 'rspec-parameterized' + +require_relative '../../sidekiq_cluster/sidekiq_cluster' + +RSpec.describe Gitlab::SidekiqCluster do # rubocop:disable RSpec/FilePath + describe '.trap_signals' do + it 'traps the given signals' do + expect(described_class).to receive(:trap).ordered.with(:INT) + expect(described_class).to receive(:trap).ordered.with(:HUP) + + described_class.trap_signals(%i(INT HUP)) + end + end + + describe '.trap_terminate' do + it 'traps the termination signals' do + expect(described_class).to receive(:trap_signals) + .with(described_class::TERMINATE_SIGNALS) + + described_class.trap_terminate { } + end + end + + describe '.trap_forward' do + it 'traps the signals to forward' do + expect(described_class).to receive(:trap_signals) + .with(described_class::FORWARD_SIGNALS) + + described_class.trap_forward { } + end + end + + describe '.signal' do + it 'sends a signal to the given process' do + allow(Process).to receive(:kill).with(:INT, 4) + expect(described_class.signal(4, :INT)).to eq(true) + end + + it 'returns false when the process does not exist' do + allow(Process).to receive(:kill).with(:INT, 4).and_raise(Errno::ESRCH) + expect(described_class.signal(4, :INT)).to eq(false) + end + end + + describe '.signal_processes' do + it 'sends a signal to every given process' do + expect(described_class).to receive(:signal).with(1, :INT) + + described_class.signal_processes([1], :INT) + end + end + + describe '.start' do + it 'starts Sidekiq with the given queues, environment and options' do + expected_options = { + env: :production, + directory: 'foo/bar', + max_concurrency: 20, + min_concurrency: 10, + timeout: 25, + dryrun: true + } + + expect(described_class).to receive(:start_sidekiq).ordered.with(%w(foo), expected_options.merge(worker_id: 0)) + expect(described_class).to receive(:start_sidekiq).ordered.with(%w(bar baz), expected_options.merge(worker_id: 1)) + + described_class.start([%w(foo), %w(bar baz)], env: :production, directory: 'foo/bar', max_concurrency: 20, min_concurrency: 10, dryrun: true) + end + + it 'starts Sidekiq with the given queues and sensible default options' do + expected_options = { + env: :development, + directory: an_instance_of(String), + max_concurrency: 50, + min_concurrency: 0, + worker_id: an_instance_of(Integer), + timeout: 25, + dryrun: false + } + + expect(described_class).to receive(:start_sidekiq).ordered.with(%w(foo bar baz), expected_options) + expect(described_class).to receive(:start_sidekiq).ordered.with(%w(solo), expected_options) + + described_class.start([%w(foo bar baz), %w(solo)]) + end + end + + describe '.start_sidekiq' do + let(:first_worker_id) { 0 } + let(:options) do + { env: :production, directory: 'foo/bar', max_concurrency: 20, min_concurrency: 0, worker_id: first_worker_id, timeout: 10, dryrun: false } + end + + let(:env) { { "ENABLE_SIDEKIQ_CLUSTER" => "1", "SIDEKIQ_WORKER_ID" => first_worker_id.to_s } } + let(:args) { ['bundle', 'exec', 'sidekiq', anything, '-eproduction', '-t10', *([anything] * 5)] } + + it 'starts a Sidekiq process' do + allow(Process).to receive(:spawn).and_return(1) + + expect(described_class).to receive(:wait_async).with(1) + expect(described_class.start_sidekiq(%w(foo), **options)).to eq(1) + end + + it 'handles duplicate queue names' do + allow(Process) + .to receive(:spawn) + .with(env, *args, anything) + .and_return(1) + + expect(described_class).to receive(:wait_async).with(1) + expect(described_class.start_sidekiq(%w(foo foo bar baz), **options)).to eq(1) + end + + it 'runs the sidekiq process in a new process group' do + expect(Process) + .to receive(:spawn) + .with(anything, *args, a_hash_including(pgroup: true)) + .and_return(1) + + allow(described_class).to receive(:wait_async) + expect(described_class.start_sidekiq(%w(foo bar baz), **options)).to eq(1) + end + end + + describe '.count_by_queue' do + it 'tallies the queue counts' do + queues = [%w(foo), %w(bar baz), %w(foo)] + + expect(described_class.count_by_queue(queues)).to eq(%w(foo) => 2, %w(bar baz) => 1) + end + end + + describe '.concurrency' do + using RSpec::Parameterized::TableSyntax + + where(:queue_count, :min, :max, :expected) do + 2 | 0 | 0 | 3 # No min or max specified + 2 | 0 | 9 | 3 # No min specified, value < max + 2 | 1 | 4 | 3 # Value between min and max + 2 | 4 | 5 | 4 # Value below range + 5 | 2 | 3 | 3 # Value above range + 2 | 1 | 1 | 1 # Value above explicit setting (min == max) + 0 | 3 | 3 | 3 # Value below explicit setting (min == max) + 1 | 4 | 3 | 3 # Min greater than max + end + + with_them do + let(:queues) { Array.new(queue_count) } + + it { expect(described_class.concurrency(queues, min, max)).to eq(expected) } + end + end + + describe '.wait_async' do + it 'waits for a process in a separate thread' do + thread = described_class.wait_async(Process.spawn('true')) + + # Upon success Process.wait just returns the PID. + expect(thread.value).to be_a_kind_of(Numeric) + end + end + + # In the X_alive? checks, we check negative PIDs sometimes as a simple way + # to be sure the pids are definitely for non-existent processes. + # Note that -1 is special, and sends the signal to every process we have permission + # for, so we use -2, -3 etc + describe '.all_alive?' do + it 'returns true if all processes are alive' do + processes = [Process.pid] + + expect(described_class.all_alive?(processes)).to eq(true) + end + + it 'returns false when a thread was not alive' do + processes = [-2] + + expect(described_class.all_alive?(processes)).to eq(false) + end + end + + describe '.any_alive?' do + it 'returns true if at least one process is alive' do + processes = [Process.pid, -2] + + expect(described_class.any_alive?(processes)).to eq(true) + end + + it 'returns false when all threads are dead' do + processes = [-2, -3] + + expect(described_class.any_alive?(processes)).to eq(false) + end + end + + describe '.write_pid' do + it 'writes the PID of the current process to the given file' do + handle = double(:handle) + + allow(File).to receive(:open).with('/dev/null', 'w').and_yield(handle) + + expect(handle).to receive(:write).with(Process.pid.to_s) + + described_class.write_pid('/dev/null') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c8664598691..25759ca50b8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -107,9 +107,7 @@ RSpec.configure do |config| warn `curl -s -o log/goroutines.log http://localhost:9236/debug/pprof/goroutine?debug=2` end end - end - - unless ENV['CI'] + else # Allow running `:focus` examples locally, # falling back to all tests when there is no `:focus` example. config.filter_run focus: true @@ -199,6 +197,14 @@ RSpec.configure do |config| if ENV['CI'] || ENV['RETRIES'] # This includes the first try, i.e. tests will be run 4 times before failing. config.default_retry_count = ENV.fetch('RETRIES', 3).to_i + 1 + + # Do not retry controller tests because rspec-retry cannot properly + # reset the controller which may contain data from last attempt. See + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73360 + config.prepend_before(:each, type: :controller) do |example| + example.metadata[:retry] = 1 + end + config.exceptions_to_hard_fail = [DeprecationToolkitEnv::DeprecationBehaviors::SelectiveRaise::RaiseDisallowedDeprecation] end @@ -232,7 +238,7 @@ RSpec.configure do |config| # We can't use an `around` hook here because the wrapping transaction # is not yet opened at the time that is triggered config.prepend_before do - Gitlab::Database.main.set_open_transactions_baseline + ApplicationRecord.set_open_transactions_baseline end config.append_before do @@ -240,7 +246,7 @@ RSpec.configure do |config| end config.append_after do - Gitlab::Database.main.reset_open_transactions_baseline + ApplicationRecord.reset_open_transactions_baseline end config.before do |example| @@ -431,6 +437,10 @@ RSpec.configure do |config| Gitlab::Metrics.reset_registry! end + config.before(:example, :eager_load) do + Rails.application.eager_load! + end + # This makes sure the `ApplicationController#can?` method is stubbed with the # original implementation for all view specs. config.before(:each, type: :view) do diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index ac35662ec93..14ef0f1b7e0 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -28,6 +28,8 @@ JS_CONSOLE_FILTER = Regexp.union([ CAPYBARA_WINDOW_SIZE = [1366, 768].freeze +SCREENSHOT_FILENAME_LENGTH = ENV['CI'] || ENV['CI_SERVER'] ? 255 : 99 + # Run Workhorse on the given host and port, proxying to Puma on a UNIX socket, # for a closer-to-production experience Capybara.register_server :puma_via_workhorse do |app, port, host, **options| @@ -113,7 +115,7 @@ Capybara.enable_aria_label = true Capybara::Screenshot.append_timestamp = false Capybara::Screenshot.register_filename_prefix_formatter(:rspec) do |example| - example.full_description.downcase.parameterize(separator: "_")[0..99] + example.full_description.downcase.parameterize(separator: "_")[0..SCREENSHOT_FILENAME_LENGTH] end # Keep only the screenshots generated from the last failing test suite Capybara::Screenshot.prune_strategy = :keep_last_run diff --git a/spec/support/database/cross-database-modification-allowlist.yml b/spec/support/database/cross-database-modification-allowlist.yml index 627967f65f3..d05812a64eb 100644 --- a/spec/support/database/cross-database-modification-allowlist.yml +++ b/spec/support/database/cross-database-modification-allowlist.yml @@ -1,1343 +1,90 @@ -- "./ee/spec/controllers/admin/geo/nodes_controller_spec.rb" -- "./ee/spec/controllers/admin/geo/projects_controller_spec.rb" -- "./ee/spec/controllers/admin/projects_controller_spec.rb" -- "./ee/spec/controllers/concerns/internal_redirect_spec.rb" -- "./ee/spec/controllers/ee/projects/jobs_controller_spec.rb" -- "./ee/spec/controllers/oauth/geo_auth_controller_spec.rb" -- "./ee/spec/controllers/projects/approver_groups_controller_spec.rb" -- "./ee/spec/controllers/projects/approvers_controller_spec.rb" -- "./ee/spec/controllers/projects/merge_requests_controller_spec.rb" -- "./ee/spec/controllers/projects/merge_requests/creations_controller_spec.rb" - "./ee/spec/controllers/projects/settings/access_tokens_controller_spec.rb" -- "./ee/spec/controllers/projects/subscriptions_controller_spec.rb" -- "./ee/spec/features/account_recovery_regular_check_spec.rb" -- "./ee/spec/features/admin/admin_audit_logs_spec.rb" -- "./ee/spec/features/admin/admin_credentials_inventory_spec.rb" -- "./ee/spec/features/admin/admin_dashboard_spec.rb" -- "./ee/spec/features/admin/admin_dev_ops_report_spec.rb" -- "./ee/spec/features/admin/admin_merge_requests_approvals_spec.rb" -- "./ee/spec/features/admin/admin_reset_pipeline_minutes_spec.rb" -- "./ee/spec/features/admin/admin_sends_notification_spec.rb" -- "./ee/spec/features/admin/admin_settings_spec.rb" -- "./ee/spec/features/admin/admin_show_new_user_signups_cap_alert_spec.rb" -- "./ee/spec/features/admin/admin_users_spec.rb" -- "./ee/spec/features/admin/geo/admin_geo_nodes_spec.rb" -- "./ee/spec/features/admin/geo/admin_geo_projects_spec.rb" -- "./ee/spec/features/admin/geo/admin_geo_replication_nav_spec.rb" -- "./ee/spec/features/admin/geo/admin_geo_sidebar_spec.rb" -- "./ee/spec/features/admin/geo/admin_geo_uploads_spec.rb" -- "./ee/spec/features/admin/groups/admin_changes_plan_spec.rb" -- "./ee/spec/features/admin/licenses/admin_uploads_license_spec.rb" -- "./ee/spec/features/admin/licenses/show_user_count_threshold_spec.rb" -- "./ee/spec/features/admin/subscriptions/admin_views_subscription_spec.rb" -- "./ee/spec/features/analytics/code_analytics_spec.rb" -- "./ee/spec/features/billings/billing_plans_spec.rb" -- "./ee/spec/features/billings/extend_reactivate_trial_spec.rb" -- "./ee/spec/features/billings/qrtly_reconciliation_alert_spec.rb" -- "./ee/spec/features/boards/boards_licensed_features_spec.rb" -- "./ee/spec/features/boards/boards_spec.rb" -- "./ee/spec/features/boards/group_boards/board_deletion_spec.rb" -- "./ee/spec/features/boards/group_boards/multiple_boards_spec.rb" -- "./ee/spec/features/boards/new_issue_spec.rb" -- "./ee/spec/features/boards/scoped_issue_board_spec.rb" -- "./ee/spec/features/boards/sidebar_spec.rb" -- "./ee/spec/features/boards/swimlanes/epics_swimlanes_drag_drop_spec.rb" -- "./ee/spec/features/boards/swimlanes/epics_swimlanes_filtering_spec.rb" -- "./ee/spec/features/boards/swimlanes/epics_swimlanes_sidebar_labels_spec.rb" -- "./ee/spec/features/boards/swimlanes/epics_swimlanes_sidebar_spec.rb" -- "./ee/spec/features/boards/swimlanes/epics_swimlanes_spec.rb" -- "./ee/spec/features/boards/user_adds_lists_to_board_spec.rb" -- "./ee/spec/features/boards/user_visits_board_spec.rb" -- "./ee/spec/features/burndown_charts_spec.rb" -- "./ee/spec/features/burnup_charts_spec.rb" -- "./ee/spec/features/ci/ci_minutes_spec.rb" -- "./ee/spec/features/ci_shared_runner_warnings_spec.rb" -- "./ee/spec/features/clusters/create_agent_spec.rb" -- "./ee/spec/features/dashboards/activity_spec.rb" -- "./ee/spec/features/dashboards/groups_spec.rb" -- "./ee/spec/features/dashboards/issues_spec.rb" -- "./ee/spec/features/dashboards/merge_requests_spec.rb" -- "./ee/spec/features/dashboards/operations_spec.rb" -- "./ee/spec/features/dashboards/projects_spec.rb" -- "./ee/spec/features/dashboards/todos_spec.rb" -- "./ee/spec/features/discussion_comments/epic_quick_actions_spec.rb" -- "./ee/spec/features/discussion_comments/epic_spec.rb" -- "./ee/spec/features/epic_boards/epic_boards_sidebar_spec.rb" -- "./ee/spec/features/epic_boards/epic_boards_spec.rb" -- "./ee/spec/features/epic_boards/multiple_epic_boards_spec.rb" -- "./ee/spec/features/epic_boards/new_epic_spec.rb" -- "./ee/spec/features/epics/delete_epic_spec.rb" -- "./ee/spec/features/epics/epic_issues_spec.rb" -- "./ee/spec/features/epics/epic_labels_spec.rb" -- "./ee/spec/features/epics/epic_show_spec.rb" -- "./ee/spec/features/epics/epics_list_spec.rb" -- "./ee/spec/features/epics/filtered_search/visual_tokens_spec.rb" -- "./ee/spec/features/epics/gfm_autocomplete_spec.rb" -- "./ee/spec/features/epics/issue_promotion_spec.rb" -- "./ee/spec/features/epics/referencing_epics_spec.rb" -- "./ee/spec/features/epics/shortcuts_epic_spec.rb" -- "./ee/spec/features/epics/todo_spec.rb" -- "./ee/spec/features/epics/update_epic_spec.rb" -- "./ee/spec/features/epics/user_uses_quick_actions_spec.rb" -- "./ee/spec/features/geo_node_spec.rb" -- "./ee/spec/features/groups/analytics/ci_cd_analytics_spec.rb" -- "./ee/spec/features/groups/analytics/cycle_analytics/charts_spec.rb" -- "./ee/spec/features/groups/analytics/cycle_analytics/filters_and_data_spec.rb" -- "./ee/spec/features/groups/analytics/cycle_analytics/multiple_value_streams_spec.rb" -- "./ee/spec/features/groups/audit_events_spec.rb" -- "./ee/spec/features/groups/billing_spec.rb" -- "./ee/spec/features/groups/contribution_analytics_spec.rb" -- "./ee/spec/features/groups/group_overview_spec.rb" -- "./ee/spec/features/groups/group_roadmap_spec.rb" -- "./ee/spec/features/groups/group_settings_spec.rb" -- "./ee/spec/features/groups/groups_security_credentials_spec.rb" -- "./ee/spec/features/groups/hooks/user_tests_hooks_spec.rb" -- "./ee/spec/features/groups/insights_spec.rb" -- "./ee/spec/features/groups/issues_spec.rb" -- "./ee/spec/features/groups/iterations/iterations_list_spec.rb" -- "./ee/spec/features/groups/iteration_spec.rb" -- "./ee/spec/features/groups/iterations/user_creates_iteration_in_cadence_spec.rb" -- "./ee/spec/features/groups/iterations/user_edits_iteration_cadence_spec.rb" -- "./ee/spec/features/groups/iterations/user_edits_iteration_spec.rb" -- "./ee/spec/features/groups/iterations/user_views_iteration_cadence_spec.rb" -- "./ee/spec/features/groups/iterations/user_views_iteration_spec.rb" -- "./ee/spec/features/groups/ldap_group_links_spec.rb" -- "./ee/spec/features/groups/ldap_settings_spec.rb" -- "./ee/spec/features/groups/members/leave_group_spec.rb" -- "./ee/spec/features/groups/members/list_members_spec.rb" -- "./ee/spec/features/groups/members/override_ldap_memberships_spec.rb" -- "./ee/spec/features/groups/new_spec.rb" -- "./ee/spec/features/groups/push_rules_spec.rb" -- "./ee/spec/features/groups/saml_providers_spec.rb" -- "./ee/spec/features/groups/scim_token_spec.rb" -- "./ee/spec/features/groups/seat_usage/seat_usage_spec.rb" -- "./ee/spec/features/groups/security/compliance_dashboards_spec.rb" -- "./ee/spec/features/groups/settings/user_configures_insights_spec.rb" -- "./ee/spec/features/groups/settings/user_searches_in_settings_spec.rb" -- "./ee/spec/features/groups/sso_spec.rb" -- "./ee/spec/features/groups/wikis_spec.rb" -- "./ee/spec/features/groups/wiki/user_views_wiki_empty_spec.rb" -- "./ee/spec/features/ide/user_commits_changes_spec.rb" -- "./ee/spec/features/ide/user_opens_ide_spec.rb" -- "./ee/spec/features/integrations/jira/jira_issues_list_spec.rb" -- "./ee/spec/features/issues/blocking_issues_spec.rb" -- "./ee/spec/features/issues/epic_in_issue_sidebar_spec.rb" -- "./ee/spec/features/issues/filtered_search/filter_issues_by_iteration_spec.rb" -- "./ee/spec/features/issues/filtered_search/filter_issues_epic_spec.rb" -- "./ee/spec/features/issues/filtered_search/filter_issues_weight_spec.rb" -- "./ee/spec/features/issues/form_spec.rb" -- "./ee/spec/features/issues/gfm_autocomplete_ee_spec.rb" -- "./ee/spec/features/issues/issue_actions_spec.rb" -- "./ee/spec/features/issues/issue_sidebar_spec.rb" -- "./ee/spec/features/issues/move_issue_resource_weight_events_spec.rb" -- "./ee/spec/features/issues/related_issues_spec.rb" -- "./ee/spec/features/issues/resource_weight_events_spec.rb" -- "./ee/spec/features/issues/user_bulk_edits_issues_spec.rb" -- "./ee/spec/features/issues/user_edits_issue_spec.rb" -- "./ee/spec/features/issues/user_uses_quick_actions_spec.rb" -- "./ee/spec/features/issues/user_views_issues_spec.rb" -- "./ee/spec/features/labels_hierarchy_spec.rb" -- "./ee/spec/features/markdown/metrics_spec.rb" -- "./ee/spec/features/merge_requests/user_filters_by_approvers_spec.rb" -- "./ee/spec/features/merge_requests/user_resets_approvers_spec.rb" -- "./ee/spec/features/merge_requests/user_views_all_merge_requests_spec.rb" -- "./ee/spec/features/merge_request/user_approves_with_password_spec.rb" -- "./ee/spec/features/merge_request/user_creates_merge_request_spec.rb" -- "./ee/spec/features/merge_request/user_creates_merge_request_with_blocking_mrs_spec.rb" -- "./ee/spec/features/merge_request/user_creates_multiple_assignees_mr_spec.rb" -- "./ee/spec/features/merge_request/user_creates_multiple_reviewers_mr_spec.rb" -- "./ee/spec/features/merge_request/user_edits_approval_rules_mr_spec.rb" -- "./ee/spec/features/merge_request/user_edits_merge_request_blocking_mrs_spec.rb" -- "./ee/spec/features/merge_request/user_edits_multiple_assignees_mr_spec.rb" -- "./ee/spec/features/merge_request/user_edits_multiple_reviewers_mr_spec.rb" -- "./ee/spec/features/merge_request/user_merges_immediately_spec.rb" -- "./ee/spec/features/merge_request/user_merges_with_push_rules_spec.rb" -- "./ee/spec/features/merge_request/user_sees_approval_widget_spec.rb" -- "./ee/spec/features/merge_request/user_sees_closing_issues_message_spec.rb" -- "./ee/spec/features/merge_request/user_sees_merge_widget_spec.rb" -- "./ee/spec/features/merge_request/user_sees_status_checks_widget_spec.rb" -- "./ee/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb" -- "./ee/spec/features/merge_request/user_sets_approval_rules_spec.rb" -- "./ee/spec/features/merge_request/user_sets_approvers_spec.rb" -- "./ee/spec/features/merge_request/user_uses_slash_commands_spec.rb" -- "./ee/spec/features/merge_request/user_views_blocked_merge_request_spec.rb" -- "./ee/spec/features/merge_trains/user_adds_merge_request_to_merge_train_spec.rb" -- "./ee/spec/features/merge_trains/user_adds_to_merge_train_when_pipeline_succeeds_spec.rb" -- "./ee/spec/features/oncall_schedules/user_creates_schedule_spec.rb" -- "./ee/spec/features/operations_nav_link_spec.rb" -- "./ee/spec/features/profiles/account_spec.rb" -- "./ee/spec/features/profiles/billing_spec.rb" -- "./ee/spec/features/projects/audit_events_spec.rb" -- "./ee/spec/features/projects/cluster_agents_spec.rb" -- "./ee/spec/features/projects/custom_projects_template_spec.rb" -- "./ee/spec/features/projects/environments/environments_spec.rb" -- "./ee/spec/features/projects/feature_flags/feature_flag_issues_spec.rb" -- "./ee/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb" -- "./ee/spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb" -- "./ee/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb" -- "./ee/spec/features/projects/insights_spec.rb" -- "./ee/spec/features/projects/integrations/user_activates_jira_spec.rb" -- "./ee/spec/features/projects/issues/user_creates_issue_spec.rb" -- "./ee/spec/features/projects/iterations/iteration_cadences_list_spec.rb" -- "./ee/spec/features/projects/iterations/iterations_list_spec.rb" -- "./ee/spec/features/projects/iterations/user_views_iteration_spec.rb" -- "./ee/spec/features/projects/jobs_spec.rb" -- "./ee/spec/features/projects/kerberos_clone_instructions_spec.rb" -- "./ee/spec/features/projects/licenses/maintainer_views_policies_spec.rb" -- "./ee/spec/features/projects/members/member_is_removed_from_project_spec.rb" -- "./ee/spec/features/projects/merge_requests/user_approves_merge_request_spec.rb" -- "./ee/spec/features/projects/merge_requests/user_edits_merge_request_spec.rb" -- "./ee/spec/features/projects/mirror_spec.rb" -- "./ee/spec/features/projects/new_project_from_template_spec.rb" -- "./ee/spec/features/projects/new_project_spec.rb" -- "./ee/spec/features/projects/path_locks_spec.rb" -- "./ee/spec/features/projects/pipelines/pipeline_spec.rb" -- "./ee/spec/features/projects/push_rules_spec.rb" -- "./ee/spec/features/projects/quality/test_case_create_spec.rb" -- "./ee/spec/features/projects/quality/test_case_list_spec.rb" -- "./ee/spec/features/projects/quality/test_case_show_spec.rb" -- "./ee/spec/features/projects/releases/user_views_release_spec.rb" -- "./ee/spec/features/projects/requirements_management/requirements_list_spec.rb" -- "./ee/spec/features/projects/security/dast_scanner_profiles_spec.rb" -- "./ee/spec/features/projects/security/dast_site_profiles_spec.rb" -- "./ee/spec/features/projects/security/user_creates_on_demand_scan_spec.rb" -- "./ee/spec/features/projects/security/user_views_security_configuration_spec.rb" -- "./ee/spec/features/projects/services/prometheus_custom_metrics_spec.rb" -- "./ee/spec/features/projects/services/user_activates_github_spec.rb" -- "./ee/spec/features/projects/settings/disable_merge_trains_setting_spec.rb" -- "./ee/spec/features/projects/settings/ee/repository_mirrors_settings_spec.rb" -- "./ee/spec/features/projects/settings/ee/service_desk_setting_spec.rb" -- "./ee/spec/features/projects/settings/issues_settings_spec.rb" -- "./ee/spec/features/projects/settings/merge_request_approvals_settings_spec.rb" -- "./ee/spec/features/projects/settings/merge_requests_settings_spec.rb" -- "./ee/spec/features/projects/settings/pipeline_subscriptions_spec.rb" -- "./ee/spec/features/projects/settings/protected_environments_spec.rb" -- "./ee/spec/features/projects/settings/user_manages_merge_pipelines_spec.rb" -- "./ee/spec/features/projects/settings/user_manages_merge_trains_spec.rb" -- "./ee/spec/features/projects_spec.rb" -- "./ee/spec/features/projects/user_applies_custom_file_template_spec.rb" -- "./ee/spec/features/projects/view_blob_with_code_owners_spec.rb" -- "./ee/spec/features/projects/wiki/user_views_wiki_empty_spec.rb" -- "./ee/spec/features/promotion_spec.rb" -- "./ee/spec/features/protected_branches_spec.rb" -- "./ee/spec/features/protected_tags_spec.rb" -- "./ee/spec/features/registrations/combined_registration_spec.rb" -- "./ee/spec/features/registrations/trial_during_signup_flow_spec.rb" -- "./ee/spec/features/registrations/user_sees_new_onboarding_flow_spec.rb" -- "./ee/spec/features/registrations/welcome_spec.rb" -- "./ee/spec/features/search/elastic/global_search_spec.rb" -- "./ee/spec/features/search/elastic/group_search_spec.rb" -- "./ee/spec/features/search/elastic/project_search_spec.rb" -- "./ee/spec/features/search/elastic/snippet_search_spec.rb" -- "./ee/spec/features/search/user_searches_for_epics_spec.rb" -- "./ee/spec/features/subscriptions/groups/edit_spec.rb" -- "./ee/spec/features/trial_registrations/signup_spec.rb" -- "./ee/spec/features/trials/capture_lead_spec.rb" -- "./ee/spec/features/trials/select_namespace_spec.rb" -- "./ee/spec/features/trials/show_trial_banner_spec.rb" -- "./ee/spec/features/users/login_spec.rb" -- "./ee/spec/finders/geo/attachment_legacy_registry_finder_spec.rb" -- "./ee/spec/finders/geo/container_repository_registry_finder_spec.rb" -- "./ee/spec/finders/geo/lfs_object_registry_finder_spec.rb" -- "./ee/spec/finders/geo/merge_request_diff_registry_finder_spec.rb" -- "./ee/spec/finders/geo/package_file_registry_finder_spec.rb" -- "./ee/spec/finders/geo/pages_deployment_registry_finder_spec.rb" -- "./ee/spec/finders/geo/pipeline_artifact_registry_finder_spec.rb" -- "./ee/spec/finders/geo/project_registry_finder_spec.rb" -- "./ee/spec/finders/merge_requests/by_approvers_finder_spec.rb" -- "./ee/spec/frontend/fixtures/analytics/value_streams.rb" -- "./ee/spec/graphql/mutations/dast_on_demand_scans/create_spec.rb" -- "./ee/spec/graphql/mutations/dast/profiles/create_spec.rb" -- "./ee/spec/graphql/mutations/dast/profiles/run_spec.rb" -- "./ee/spec/graphql/mutations/dast/profiles/update_spec.rb" -- "./ee/spec/graphql/mutations/merge_requests/accept_spec.rb" -- "./ee/spec/graphql/resolvers/geo/group_wiki_repository_registries_resolver_spec.rb" -- "./ee/spec/graphql/resolvers/geo/lfs_object_registries_resolver_spec.rb" -- "./ee/spec/graphql/resolvers/geo/merge_request_diff_registries_resolver_spec.rb" -- "./ee/spec/graphql/resolvers/geo/package_file_registries_resolver_spec.rb" -- "./ee/spec/graphql/resolvers/geo/pages_deployment_registries_resolver_spec.rb" -- "./ee/spec/graphql/resolvers/geo/pipeline_artifact_registries_resolver_spec.rb" -- "./ee/spec/graphql/resolvers/geo/snippet_repository_registries_resolver_spec.rb" -- "./ee/spec/graphql/resolvers/geo/terraform_state_version_registries_resolver_spec.rb" -- "./ee/spec/graphql/resolvers/geo/upload_registries_resolver_spec.rb" -- "./ee/spec/helpers/application_helper_spec.rb" -- "./ee/spec/helpers/ee/geo_helper_spec.rb" -- "./ee/spec/lib/analytics/devops_adoption/snapshot_calculator_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/backfill_iteration_cadence_id_for_boards_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/backfill_version_data_from_gitaly_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/create_security_setting_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/fix_ruby_object_in_audit_events_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_check_progress_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_in_batch_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/migrate_devops_segments_to_groups_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/migrate_security_scans_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/move_epic_issues_after_epics_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_any_approval_rule_for_merge_requests_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_any_approval_rule_for_projects_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_latest_pipeline_ids_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_namespace_statistics_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_resolved_on_default_branch_column_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_uuids_for_security_findings_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_vulnerability_feedback_pipeline_id_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_vulnerability_historical_statistics_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/prune_orphaned_geo_events_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/remove_duplicate_cs_findings_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/remove_duplicated_cs_findings_without_vulnerability_id_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/remove_inaccessible_epic_todos_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/remove_undefined_occurrence_confidence_level_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/remove_undefined_occurrence_severity_level_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/remove_undefined_vulnerability_confidence_level_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/remove_undefined_vulnerability_severity_level_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/update_location_fingerprint_for_container_scanning_findings_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/update_vulnerabilities_from_dismissal_feedback_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/update_vulnerabilities_to_dismissed_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/update_vulnerability_confidence_spec.rb" -- "./ee/spec/lib/ee/gitlab/database/connection_spec.rb" -- "./ee/spec/lib/ee/gitlab/database_spec.rb" -- "./ee/spec/lib/ee/gitlab/middleware/read_only_spec.rb" -- "./ee/spec/lib/ee/gitlab/usage_data_spec.rb" -- "./ee/spec/lib/gitlab/background_migration/fix_orphan_promoted_issues_spec.rb" -- "./ee/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb" - "./ee/spec/lib/gitlab/ci/templates/Jobs/dast_default_branch_gitlab_ci_yaml_spec.rb" -- "./ee/spec/lib/gitlab/geo/base_request_spec.rb" -- "./ee/spec/lib/gitlab/geo/database_tasks_spec.rb" -- "./ee/spec/lib/gitlab/geo/event_gap_tracking_spec.rb" -- "./ee/spec/lib/gitlab/geo/geo_tasks_spec.rb" -- "./ee/spec/lib/gitlab/geo/jwt_request_decoder_spec.rb" -- "./ee/spec/lib/gitlab/geo/log_cursor/events/design_repository_updated_event_spec.rb" -- "./ee/spec/lib/gitlab/geo/log_cursor/events/job_artifact_deleted_event_spec.rb" -- "./ee/spec/lib/gitlab/geo/log_cursor/events/repository_created_event_spec.rb" -- "./ee/spec/lib/gitlab/geo/log_cursor/events/repository_updated_event_spec.rb" -- "./ee/spec/lib/gitlab/geo/oauth/login_state_spec.rb" -- "./ee/spec/lib/gitlab/geo/oauth/logout_token_spec.rb" -- "./ee/spec/lib/gitlab/geo/oauth/session_spec.rb" -- "./ee/spec/lib/gitlab/geo/registry_batcher_spec.rb" -- "./ee/spec/lib/gitlab/geo/replicable_model_spec.rb" -- "./ee/spec/lib/gitlab/geo/replication/blob_downloader_spec.rb" -- "./ee/spec/lib/gitlab/geo/replication/file_transfer_spec.rb" -- "./ee/spec/lib/gitlab/geo/replicator_spec.rb" -- "./ee/spec/lib/gitlab/git_access_spec.rb" -- "./ee/spec/lib/pseudonymizer/dumper_spec.rb" -- "./ee/spec/lib/system_check/geo/geo_database_configured_check_spec.rb" -- "./ee/spec/lib/system_check/geo/http_connection_check_spec.rb" -- "./ee/spec/lib/system_check/rake_task/geo_task_spec.rb" - "./ee/spec/mailers/notify_spec.rb" -- "./ee/spec/migrations/20190926180443_schedule_epic_issues_after_epics_move_spec.rb" -- "./ee/spec/migrations/add_non_null_constraint_for_escalation_rule_on_pending_alert_escalations_spec.rb" -- "./ee/spec/migrations/add_unique_constraint_to_software_licenses_spec.rb" -- "./ee/spec/migrations/backfill_namespace_statistics_with_wiki_size_spec.rb" -- "./ee/spec/migrations/backfill_operations_feature_flags_iid_spec.rb" -- "./ee/spec/migrations/backfill_software_licenses_spdx_identifiers_spec.rb" -- "./ee/spec/migrations/backfill_version_author_and_created_at_spec.rb" -- "./ee/spec/migrations/cleanup_deploy_access_levels_for_removed_groups_spec.rb" -- "./ee/spec/migrations/create_elastic_reindexing_subtasks_spec.rb" -- "./ee/spec/migrations/fix_any_approver_rule_for_projects_spec.rb" -- "./ee/spec/migrations/migrate_design_notes_mentions_to_db_spec.rb" -- "./ee/spec/migrations/migrate_epic_mentions_to_db_spec.rb" -- "./ee/spec/migrations/migrate_epic_notes_mentions_to_db_spec.rb" -- "./ee/spec/migrations/migrate_license_management_artifacts_to_license_scanning_spec.rb" -- "./ee/spec/migrations/migrate_saml_identities_to_scim_identities_spec.rb" -- "./ee/spec/migrations/migrate_scim_identities_to_saml_for_new_users_spec.rb" -- "./ee/spec/migrations/migrate_vulnerability_dismissal_feedback_spec.rb" -- "./ee/spec/migrations/migrate_vulnerability_dismissals_spec.rb" -- "./ee/spec/migrations/nullify_feature_flag_plaintext_tokens_spec.rb" -- "./ee/spec/migrations/populate_vulnerability_historical_statistics_for_year_spec.rb" -- "./ee/spec/migrations/remove_creations_in_gitlab_subscription_histories_spec.rb" -- "./ee/spec/migrations/remove_cycle_analytics_total_stage_data_spec.rb" -- "./ee/spec/migrations/remove_duplicated_cs_findings_spec.rb" -- "./ee/spec/migrations/remove_duplicated_cs_findings_without_vulnerability_id_spec.rb" -- "./ee/spec/migrations/remove_schedule_and_status_null_constraints_from_pending_escalations_alert_spec.rb" -- "./ee/spec/migrations/schedule_fix_orphan_promoted_issues_spec.rb" -- "./ee/spec/migrations/schedule_fix_ruby_object_in_audit_events_spec.rb" -- "./ee/spec/migrations/schedule_merge_request_any_approval_rule_migration_spec.rb" -- "./ee/spec/migrations/schedule_populate_dismissed_state_for_vulnerabilities_spec.rb" -- "./ee/spec/migrations/schedule_populate_resolved_on_default_branch_column_spec.rb" -- "./ee/spec/migrations/schedule_populate_vulnerability_historical_statistics_spec.rb" -- "./ee/spec/migrations/schedule_project_any_approval_rule_migration_spec.rb" -- "./ee/spec/migrations/schedule_remove_inaccessible_epic_todos_spec.rb" -- "./ee/spec/migrations/schedule_sync_blocking_issues_count_spec.rb" -- "./ee/spec/migrations/schedule_uuid_population_for_security_findings2_spec.rb" -- "./ee/spec/migrations/set_report_type_for_vulnerabilities_spec.rb" -- "./ee/spec/migrations/set_resolved_state_on_vulnerabilities_spec.rb" -- "./ee/spec/migrations/update_cs_vulnerability_confidence_column_spec.rb" -- "./ee/spec/migrations/update_gitlab_subscriptions_start_at_post_eoa_spec.rb" -- "./ee/spec/migrations/update_location_fingerprint_column_for_cs_spec.rb" -- "./ee/spec/migrations/update_occurrence_severity_column_spec.rb" -- "./ee/spec/migrations/update_undefined_confidence_from_occurrences_spec.rb" -- "./ee/spec/migrations/update_undefined_confidence_from_vulnerabilities_spec.rb" -- "./ee/spec/migrations/update_vulnerability_severity_column_spec.rb" -- "./ee/spec/models/analytics/cycle_analytics/group_level_spec.rb" -- "./ee/spec/models/approval_merge_request_rule_spec.rb" -- "./ee/spec/models/approval_project_rule_spec.rb" -- "./ee/spec/models/approval_state_spec.rb" -- "./ee/spec/models/approval_wrapped_code_owner_rule_spec.rb" -- "./ee/spec/models/approval_wrapped_rule_spec.rb" -- "./ee/spec/models/approver_group_spec.rb" - "./ee/spec/models/ci/bridge_spec.rb" - "./ee/spec/models/ci/build_spec.rb" - "./ee/spec/models/ci/minutes/additional_pack_spec.rb" -- "./ee/spec/models/ci/pipeline_spec.rb" -- "./ee/spec/models/ci/subscriptions/project_spec.rb" -- "./ee/spec/models/concerns/approval_rule_like_spec.rb" -- "./ee/spec/models/concerns/approver_migrate_hook_spec.rb" -- "./ee/spec/models/dora/daily_metrics_spec.rb" - "./ee/spec/models/ee/ci/job_artifact_spec.rb" -- "./ee/spec/models/ee/ci/pipeline_artifact_spec.rb" -- "./ee/spec/models/ee/ci/runner_spec.rb" -- "./ee/spec/models/ee/merge_request_diff_spec.rb" -- "./ee/spec/models/ee/pages_deployment_spec.rb" -- "./ee/spec/models/ee/terraform/state_version_spec.rb" -- "./ee/spec/models/geo/container_repository_registry_spec.rb" -- "./ee/spec/models/geo/deleted_project_spec.rb" -- "./ee/spec/models/geo/design_registry_spec.rb" -- "./ee/spec/models/geo/job_artifact_registry_spec.rb" -- "./ee/spec/models/geo_node_namespace_link_spec.rb" -- "./ee/spec/models/geo_node_spec.rb" -- "./ee/spec/models/geo_node_status_spec.rb" -- "./ee/spec/models/geo/package_file_registry_spec.rb" -- "./ee/spec/models/geo/project_registry_spec.rb" - "./ee/spec/models/group_member_spec.rb" -- "./ee/spec/models/group_wiki_repository_spec.rb" -- "./ee/spec/models/merge_request_spec.rb" -- "./ee/spec/models/packages/package_file_spec.rb" -- "./ee/spec/models/project_spec.rb" -- "./ee/spec/models/requirements_management/requirement_spec.rb" -- "./ee/spec/models/snippet_repository_spec.rb" -- "./ee/spec/models/upload_spec.rb" -- "./ee/spec/models/visible_approvable_spec.rb" -- "./ee/spec/policies/ci/build_policy_spec.rb" -- "./ee/spec/presenters/approval_rule_presenter_spec.rb" -- "./ee/spec/presenters/merge_request_presenter_spec.rb" - "./ee/spec/replicators/geo/pipeline_artifact_replicator_spec.rb" - "./ee/spec/replicators/geo/terraform_state_version_replicator_spec.rb" -- "./ee/spec/requests/api/ci/pipelines_spec.rb" -- "./ee/spec/requests/api/geo_nodes_spec.rb" -- "./ee/spec/requests/api/geo_replication_spec.rb" -- "./ee/spec/requests/api/graphql/mutations/dast_on_demand_scans/create_spec.rb" -- "./ee/spec/requests/api/graphql/mutations/dast/profiles/create_spec.rb" -- "./ee/spec/requests/api/graphql/mutations/dast/profiles/run_spec.rb" -- "./ee/spec/requests/api/graphql/mutations/dast/profiles/update_spec.rb" -- "./ee/spec/requests/api/graphql/project/pipeline/dast_profile_spec.rb" -- "./ee/spec/requests/api/merge_request_approval_rules_spec.rb" -- "./ee/spec/requests/api/merge_requests_spec.rb" -- "./ee/spec/requests/api/project_approval_rules_spec.rb" -- "./ee/spec/requests/api/project_approval_settings_spec.rb" -- "./ee/spec/requests/api/project_approvals_spec.rb" -- "./ee/spec/requests/api/project_snapshots_spec.rb" -- "./ee/spec/requests/api/status_checks_spec.rb" -- "./ee/spec/requests/api/vulnerability_findings_spec.rb" -- "./ee/spec/requests/projects/merge_requests_controller_spec.rb" -- "./ee/spec/routing/admin_routing_spec.rb" -- "./ee/spec/serializers/dashboard_operations_project_entity_spec.rb" -- "./ee/spec/serializers/ee/evidences/release_entity_spec.rb" -- "./ee/spec/serializers/ee/user_serializer_spec.rb" -- "./ee/spec/serializers/evidences/evidence_entity_spec.rb" -- "./ee/spec/serializers/merge_request_widget_entity_spec.rb" -- "./ee/spec/serializers/pipeline_serializer_spec.rb" -- "./ee/spec/services/approval_rules/create_service_spec.rb" -- "./ee/spec/services/approval_rules/finalize_service_spec.rb" -- "./ee/spec/services/approval_rules/merge_request_rule_destroy_service_spec.rb" -- "./ee/spec/services/approval_rules/params_filtering_service_spec.rb" -- "./ee/spec/services/approval_rules/project_rule_destroy_service_spec.rb" -- "./ee/spec/services/approval_rules/update_service_spec.rb" -- "./ee/spec/services/app_sec/dast/profiles/create_service_spec.rb" -- "./ee/spec/services/app_sec/dast/profiles/update_service_spec.rb" -- "./ee/spec/services/app_sec/dast/scans/create_service_spec.rb" -- "./ee/spec/services/app_sec/dast/scans/run_service_spec.rb" -- "./ee/spec/services/ci/compare_license_scanning_reports_service_spec.rb" -- "./ee/spec/services/ci/compare_metrics_reports_service_spec.rb" -- "./ee/spec/services/ci/create_pipeline_service/dast_configuration_spec.rb" - "./ee/spec/services/ci/destroy_pipeline_service_spec.rb" -- "./ee/spec/services/ci/minutes/track_live_consumption_service_spec.rb" -- "./ee/spec/services/ci/minutes/update_build_minutes_service_spec.rb" -- "./ee/spec/services/ci/register_job_service_spec.rb" - "./ee/spec/services/ci/retry_build_service_spec.rb" -- "./ee/spec/services/ci/run_dast_scan_service_spec.rb" - "./ee/spec/services/ci/subscribe_bridge_service_spec.rb" -- "./ee/spec/services/ci/sync_reports_to_approval_rules_service_spec.rb" -- "./ee/spec/services/ci/trigger_downstream_subscription_service_spec.rb" -- "./ee/spec/services/dast_on_demand_scans/create_service_spec.rb" - "./ee/spec/services/deployments/auto_rollback_service_spec.rb" - "./ee/spec/services/ee/ci/job_artifacts/destroy_all_expired_service_spec.rb" -- "./ee/spec/services/ee/ci/job_artifacts/destroy_batch_service_spec.rb" -- "./ee/spec/services/ee/integrations/test/project_service_spec.rb" -- "./ee/spec/services/ee/issuable/destroy_service_spec.rb" -- "./ee/spec/services/ee/merge_requests/refresh_service_spec.rb" -- "./ee/spec/services/ee/merge_requests/update_service_spec.rb" -- "./ee/spec/services/ee/notification_service_spec.rb" -- "./ee/spec/services/ee/post_receive_service_spec.rb" -- "./ee/spec/services/ee/releases/create_evidence_service_spec.rb" - "./ee/spec/services/ee/users/destroy_service_spec.rb" -- "./ee/spec/services/external_status_checks/create_service_spec.rb" -- "./ee/spec/services/external_status_checks/destroy_service_spec.rb" -- "./ee/spec/services/external_status_checks/update_service_spec.rb" -- "./ee/spec/services/geo/container_repository_sync_service_spec.rb" -- "./ee/spec/services/geo/hashed_storage_migrated_event_store_spec.rb" -- "./ee/spec/services/geo/hashed_storage_migration_service_spec.rb" -- "./ee/spec/services/geo/node_create_service_spec.rb" -- "./ee/spec/services/geo/node_status_request_service_spec.rb" -- "./ee/spec/services/geo/node_update_service_spec.rb" -- "./ee/spec/services/geo/project_housekeeping_service_spec.rb" -- "./ee/spec/services/geo/registry_consistency_service_spec.rb" -- "./ee/spec/services/geo/repositories_changed_event_store_spec.rb" -- "./ee/spec/services/geo/repository_updated_event_store_spec.rb" -- "./ee/spec/services/geo/repository_verification_reset_spec.rb" -- "./ee/spec/services/geo/repository_verification_secondary_service_spec.rb" -- "./ee/spec/services/merge_requests/merge_service_spec.rb" -- "./ee/spec/services/merge_requests/reset_approvals_service_spec.rb" -- "./ee/spec/services/merge_requests/sync_report_approver_approval_rules_spec.rb" - "./ee/spec/services/projects/transfer_service_spec.rb" - "./ee/spec/services/security/security_orchestration_policies/rule_schedule_service_spec.rb" -- "./ee/spec/services/todo_service_spec.rb" -- "./ee/spec/services/vulnerability_feedback/create_service_spec.rb" -- "./ee/spec/services/wiki_pages/create_service_spec.rb" -- "./ee/spec/services/wiki_pages/destroy_service_spec.rb" -- "./ee/spec/services/wiki_pages/update_service_spec.rb" -- "./ee/spec/support/shared_examples/fixtures/analytics_value_streams_shared_examples.rb" -- "./ee/spec/support/shared_examples/graphql/geo/geo_registries_resolver_shared_examples.rb" -- "./ee/spec/support/shared_examples/graphql/mutations/dast_on_demand_scans_shared_examples.rb" -- "./ee/spec/support/shared_examples/graphql/mutations/dast_on_demand_scan_with_user_abilities_shared_examples.rb" -- "./ee/spec/support/shared_examples/lib/gitlab/geo/geo_log_cursor_event_shared_examples.rb" -- "./ee/spec/support/shared_examples/lib/gitlab/geo/geo_logs_event_source_info_shared_examples.rb" -- "./ee/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb" -- "./ee/spec/support/shared_examples/models/concerns/replicable_model_shared_examples.rb" -- "./ee/spec/support/shared_examples/models/concerns/verifiable_replicator_shared_examples.rb" -- "./ee/spec/support/shared_examples/policies/protected_environments_shared_examples.rb" -- "./ee/spec/support/shared_examples/requests/api/project_approval_rules_api_shared_examples.rb" -- "./ee/spec/support/shared_examples/services/audit_event_logging_shared_examples.rb" -- "./ee/spec/support/shared_examples/services/build_execute_shared_examples.rb" -- "./ee/spec/support/shared_examples/services/dast_on_demand_scans_shared_examples.rb" -- "./ee/spec/support/shared_examples/services/geo_event_store_shared_examples.rb" -- "./ee/spec/tasks/geo_rake_spec.rb" -- "./ee/spec/tasks/gitlab/geo_rake_spec.rb" -- "./ee/spec/workers/geo/file_download_dispatch_worker_spec.rb" -- "./ee/spec/workers/geo/metrics_update_worker_spec.rb" -- "./ee/spec/workers/geo/prune_event_log_worker_spec.rb" -- "./ee/spec/workers/geo/registry_sync_worker_spec.rb" -- "./ee/spec/workers/geo/repository_cleanup_worker_spec.rb" -- "./ee/spec/workers/geo/repository_sync_worker_spec.rb" -- "./ee/spec/workers/geo/repository_verification/secondary/scheduler_worker_spec.rb" -- "./ee/spec/workers/geo/repository_verification/secondary/single_worker_spec.rb" -- "./ee/spec/workers/geo/verification_worker_spec.rb" -- "./ee/spec/workers/refresh_license_compliance_checks_worker_spec.rb" - "./spec/controllers/abuse_reports_controller_spec.rb" - "./spec/controllers/admin/spam_logs_controller_spec.rb" - "./spec/controllers/admin/users_controller_spec.rb" - "./spec/controllers/omniauth_callbacks_controller_spec.rb" - "./spec/controllers/projects/issues_controller_spec.rb" -- "./spec/controllers/projects/jobs_controller_spec.rb" -- "./spec/controllers/projects/merge_requests/content_controller_spec.rb" -- "./spec/controllers/projects/merge_requests_controller_spec.rb" - "./spec/controllers/projects/pipelines_controller_spec.rb" -- "./spec/controllers/projects/pipelines/tests_controller_spec.rb" - "./spec/controllers/projects/settings/access_tokens_controller_spec.rb" -- "./spec/controllers/projects/tags_controller_spec.rb" -- "./spec/controllers/sent_notifications_controller_spec.rb" -- "./spec/factories_spec.rb" -- "./spec/features/action_cable_logging_spec.rb" -- "./spec/features/admin/admin_abuse_reports_spec.rb" -- "./spec/features/admin/admin_appearance_spec.rb" -- "./spec/features/admin/admin_broadcast_messages_spec.rb" -- "./spec/features/admin/admin_builds_spec.rb" -- "./spec/features/admin/admin_dev_ops_report_spec.rb" -- "./spec/features/admin/admin_disables_git_access_protocol_spec.rb" -- "./spec/features/admin/admin_disables_two_factor_spec.rb" -- "./spec/features/admin/admin_groups_spec.rb" -- "./spec/features/admin/admin_hooks_spec.rb" -- "./spec/features/admin/admin_labels_spec.rb" -- "./spec/features/admin/admin_mode/login_spec.rb" -- "./spec/features/admin/admin_mode/logout_spec.rb" -- "./spec/features/admin/admin_mode_spec.rb" -- "./spec/features/admin/admin_mode/workers_spec.rb" -- "./spec/features/admin/admin_projects_spec.rb" -- "./spec/features/admin/admin_runners_spec.rb" -- "./spec/features/admin/admin_search_settings_spec.rb" -- "./spec/features/admin/admin_serverless_domains_spec.rb" -- "./spec/features/admin/admin_settings_spec.rb" -- "./spec/features/admin/admin_users_impersonation_tokens_spec.rb" -- "./spec/features/admin/admin_uses_repository_checks_spec.rb" -- "./spec/features/admin/clusters/eks_spec.rb" -- "./spec/features/admin/dashboard_spec.rb" -- "./spec/features/admin/integrations/user_activates_mattermost_slash_command_spec.rb" -- "./spec/features/admin/users/user_spec.rb" -- "./spec/features/admin/users/users_spec.rb" -- "./spec/features/alert_management/alert_details_spec.rb" -- "./spec/features/alert_management/alert_management_list_spec.rb" -- "./spec/features/alert_management_spec.rb" -- "./spec/features/alert_management/user_filters_alerts_by_status_spec.rb" -- "./spec/features/alert_management/user_searches_alerts_spec.rb" -- "./spec/features/alert_management/user_updates_alert_status_spec.rb" -- "./spec/features/alerts_settings/user_views_alerts_settings_spec.rb" -- "./spec/features/atom/dashboard_spec.rb" -- "./spec/features/boards/boards_spec.rb" -- "./spec/features/boards/focus_mode_spec.rb" -- "./spec/features/boards/issue_ordering_spec.rb" -- "./spec/features/boards/keyboard_shortcut_spec.rb" -- "./spec/features/boards/multiple_boards_spec.rb" -- "./spec/features/boards/new_issue_spec.rb" -- "./spec/features/boards/reload_boards_on_browser_back_spec.rb" -- "./spec/features/boards/sidebar_due_date_spec.rb" -- "./spec/features/boards/sidebar_labels_in_namespaces_spec.rb" -- "./spec/features/boards/sidebar_labels_spec.rb" -- "./spec/features/boards/sidebar_milestones_spec.rb" -- "./spec/features/boards/sidebar_spec.rb" -- "./spec/features/boards/user_adds_lists_to_board_spec.rb" -- "./spec/features/boards/user_visits_board_spec.rb" -- "./spec/features/broadcast_messages_spec.rb" -- "./spec/features/calendar_spec.rb" -- "./spec/features/callouts/registration_enabled_spec.rb" -- "./spec/features/clusters/cluster_detail_page_spec.rb" -- "./spec/features/clusters/cluster_health_dashboard_spec.rb" -- "./spec/features/commit_spec.rb" -- "./spec/features/commits_spec.rb" -- "./spec/features/commits/user_uses_quick_actions_spec.rb" -- "./spec/features/contextual_sidebar_spec.rb" -- "./spec/features/cycle_analytics_spec.rb" -- "./spec/features/dashboard/activity_spec.rb" -- "./spec/features/dashboard/archived_projects_spec.rb" -- "./spec/features/dashboard/datetime_on_tooltips_spec.rb" -- "./spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb" -- "./spec/features/dashboard/groups_list_spec.rb" -- "./spec/features/dashboard/group_spec.rb" -- "./spec/features/dashboard/issues_filter_spec.rb" -- "./spec/features/dashboard/issues_spec.rb" -- "./spec/features/dashboard/label_filter_spec.rb" -- "./spec/features/dashboard/merge_requests_spec.rb" -- "./spec/features/dashboard/milestones_spec.rb" -- "./spec/features/dashboard/project_member_activity_index_spec.rb" -- "./spec/features/dashboard/projects_spec.rb" -- "./spec/features/dashboard/root_spec.rb" -- "./spec/features/dashboard/shortcuts_spec.rb" -- "./spec/features/dashboard/snippets_spec.rb" -- "./spec/features/dashboard/todos/todos_filtering_spec.rb" -- "./spec/features/dashboard/todos/todos_spec.rb" -- "./spec/features/dashboard/user_filters_projects_spec.rb" -- "./spec/features/discussion_comments/commit_spec.rb" -- "./spec/features/discussion_comments/issue_spec.rb" -- "./spec/features/discussion_comments/merge_request_spec.rb" -- "./spec/features/discussion_comments/snippets_spec.rb" -- "./spec/features/error_pages_spec.rb" -- "./spec/features/error_tracking/user_filters_errors_by_status_spec.rb" -- "./spec/features/error_tracking/user_searches_sentry_errors_spec.rb" -- "./spec/features/error_tracking/user_sees_error_details_spec.rb" -- "./spec/features/error_tracking/user_sees_error_index_spec.rb" -- "./spec/features/expand_collapse_diffs_spec.rb" -- "./spec/features/explore/groups_list_spec.rb" -- "./spec/features/explore/groups_spec.rb" -- "./spec/features/explore/user_explores_projects_spec.rb" -- "./spec/features/file_uploads/attachment_spec.rb" -- "./spec/features/file_uploads/ci_artifact_spec.rb" -- "./spec/features/file_uploads/git_lfs_spec.rb" -- "./spec/features/file_uploads/graphql_add_design_spec.rb" -- "./spec/features/file_uploads/group_import_spec.rb" -- "./spec/features/file_uploads/maven_package_spec.rb" -- "./spec/features/file_uploads/multipart_invalid_uploads_spec.rb" -- "./spec/features/file_uploads/nuget_package_spec.rb" -- "./spec/features/file_uploads/project_import_spec.rb" -- "./spec/features/file_uploads/rubygem_package_spec.rb" -- "./spec/features/file_uploads/user_avatar_spec.rb" -- "./spec/features/frequently_visited_projects_and_groups_spec.rb" -- "./spec/features/gitlab_experiments_spec.rb" -- "./spec/features/global_search_spec.rb" -- "./spec/features/groups/activity_spec.rb" -- "./spec/features/groups/board_sidebar_spec.rb" -- "./spec/features/groups/board_spec.rb" -- "./spec/features/groups/clusters/eks_spec.rb" -- "./spec/features/groups/clusters/user_spec.rb" -- "./spec/features/groups/container_registry_spec.rb" -- "./spec/features/groups/dependency_proxy_spec.rb" -- "./spec/features/groups/empty_states_spec.rb" -- "./spec/features/groups/import_export/connect_instance_spec.rb" -- "./spec/features/groups/import_export/export_file_spec.rb" -- "./spec/features/groups/import_export/import_file_spec.rb" -- "./spec/features/groups/integrations/user_activates_mattermost_slash_command_spec.rb" -- "./spec/features/groups/issues_spec.rb" -- "./spec/features/groups/labels/index_spec.rb" -- "./spec/features/groups/labels/search_labels_spec.rb" -- "./spec/features/groups/labels/sort_labels_spec.rb" -- "./spec/features/groups/labels/subscription_spec.rb" -- "./spec/features/groups/members/filter_members_spec.rb" -- "./spec/features/groups/members/leave_group_spec.rb" -- "./spec/features/groups/members/list_members_spec.rb" -- "./spec/features/groups/members/manage_groups_spec.rb" -- "./spec/features/groups/members/manage_members_spec.rb" -- "./spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb" -- "./spec/features/groups/members/master_manages_access_requests_spec.rb" -- "./spec/features/groups/members/search_members_spec.rb" -- "./spec/features/groups/members/sort_members_spec.rb" -- "./spec/features/groups/members/tabs_spec.rb" -- "./spec/features/groups/merge_requests_spec.rb" -- "./spec/features/groups/milestones/gfm_autocomplete_spec.rb" -- "./spec/features/groups/milestone_spec.rb" -- "./spec/features/groups/milestones_sorting_spec.rb" -- "./spec/features/groups/packages_spec.rb" -- "./spec/features/groups/settings/group_badges_spec.rb" -- "./spec/features/groups/settings/packages_and_registries_spec.rb" -- "./spec/features/groups/settings/repository_spec.rb" -- "./spec/features/groups/settings/user_searches_in_settings_spec.rb" -- "./spec/features/groups/show_spec.rb" -- "./spec/features/groups_spec.rb" -- "./spec/features/groups/user_browse_projects_group_page_spec.rb" -- "./spec/features/groups/user_sees_users_dropdowns_in_issuables_list_spec.rb" -- "./spec/features/help_pages_spec.rb" -- "./spec/features/ide_spec.rb" -- "./spec/features/ide/user_commits_changes_spec.rb" -- "./spec/features/ide/user_opens_merge_request_spec.rb" -- "./spec/features/import/manifest_import_spec.rb" -- "./spec/features/incidents/incident_details_spec.rb" -- "./spec/features/incidents/incidents_list_spec.rb" -- "./spec/features/incidents/user_creates_new_incident_spec.rb" -- "./spec/features/incidents/user_filters_incidents_by_status_spec.rb" -- "./spec/features/incidents/user_searches_incidents_spec.rb" -- "./spec/features/incidents/user_views_incident_spec.rb" -- "./spec/features/issuables/issuable_list_spec.rb" -- "./spec/features/issuables/markdown_references/internal_references_spec.rb" -- "./spec/features/issuables/markdown_references/jira_spec.rb" -- "./spec/features/issuables/sorting_list_spec.rb" -- "./spec/features/issuables/user_sees_sidebar_spec.rb" -- "./spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb" -- "./spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb" -- "./spec/features/issues/csv_spec.rb" -- "./spec/features/issues/discussion_lock_spec.rb" -- "./spec/features/issues/filtered_search/dropdown_assignee_spec.rb" -- "./spec/features/issues/filtered_search/dropdown_author_spec.rb" -- "./spec/features/issues/filtered_search/dropdown_base_spec.rb" -- "./spec/features/issues/filtered_search/dropdown_emoji_spec.rb" -- "./spec/features/issues/filtered_search/dropdown_hint_spec.rb" -- "./spec/features/issues/filtered_search/dropdown_label_spec.rb" -- "./spec/features/issues/filtered_search/dropdown_milestone_spec.rb" -- "./spec/features/issues/filtered_search/dropdown_release_spec.rb" -- "./spec/features/issues/filtered_search/filter_issues_spec.rb" -- "./spec/features/issues/filtered_search/recent_searches_spec.rb" -- "./spec/features/issues/filtered_search/search_bar_spec.rb" -- "./spec/features/issues/filtered_search/visual_tokens_spec.rb" -- "./spec/features/issues/form_spec.rb" -- "./spec/features/issues/gfm_autocomplete_spec.rb" -- "./spec/features/issues/group_label_sidebar_spec.rb" -- "./spec/features/issues/incident_issue_spec.rb" - "./spec/features/issues/issue_detail_spec.rb" -- "./spec/features/issues/issue_header_spec.rb" -- "./spec/features/issues/issue_sidebar_spec.rb" -- "./spec/features/issues/keyboard_shortcut_spec.rb" -- "./spec/features/issues/markdown_toolbar_spec.rb" -- "./spec/features/issues/move_spec.rb" -- "./spec/features/issues/note_polling_spec.rb" -- "./spec/features/issues/notes_on_issues_spec.rb" -- "./spec/features/issues/related_issues_spec.rb" -- "./spec/features/issues/resource_label_events_spec.rb" -- "./spec/features/issues/service_desk_spec.rb" -- "./spec/features/issues/spam_issues_spec.rb" -- "./spec/features/issues/todo_spec.rb" -- "./spec/features/issues/user_bulk_edits_issues_labels_spec.rb" -- "./spec/features/issues/user_bulk_edits_issues_spec.rb" -- "./spec/features/issues/user_comments_on_issue_spec.rb" -- "./spec/features/issues/user_creates_branch_and_merge_request_spec.rb" -- "./spec/features/issues/user_creates_confidential_merge_request_spec.rb" -- "./spec/features/issues/user_creates_issue_by_email_spec.rb" -- "./spec/features/issues/user_creates_issue_spec.rb" -- "./spec/features/issues/user_edits_issue_spec.rb" -- "./spec/features/issues/user_filters_issues_spec.rb" -- "./spec/features/issues/user_interacts_with_awards_spec.rb" -- "./spec/features/issues/user_invites_from_a_comment_spec.rb" -- "./spec/features/issues/user_resets_their_incoming_email_token_spec.rb" -- "./spec/features/issues/user_sees_empty_state_spec.rb" -- "./spec/features/issues/user_sees_live_update_spec.rb" -- "./spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb" -- "./spec/features/issues/user_sorts_issue_comments_spec.rb" -- "./spec/features/issues/user_sorts_issues_spec.rb" -- "./spec/features/issues/user_toggles_subscription_spec.rb" -- "./spec/features/issues/user_uses_quick_actions_spec.rb" -- "./spec/features/issues/user_views_issue_spec.rb" -- "./spec/features/issues/user_views_issues_spec.rb" -- "./spec/features/jira_connect/branches_spec.rb" -- "./spec/features/labels_hierarchy_spec.rb" -- "./spec/features/markdown/copy_as_gfm_spec.rb" -- "./spec/features/markdown/gitlab_flavored_markdown_spec.rb" -- "./spec/features/markdown/keyboard_shortcuts_spec.rb" -- "./spec/features/markdown/math_spec.rb" -- "./spec/features/markdown/mermaid_spec.rb" -- "./spec/features/markdown/metrics_spec.rb" -- "./spec/features/merge_request/batch_comments_spec.rb" -- "./spec/features/merge_request/close_reopen_report_toggle_spec.rb" -- "./spec/features/merge_request/maintainer_edits_fork_spec.rb" -- "./spec/features/merge_request/merge_request_discussion_lock_spec.rb" -- "./spec/features/merge_requests/filters_generic_behavior_spec.rb" -- "./spec/features/merge_requests/user_exports_as_csv_spec.rb" -- "./spec/features/merge_requests/user_filters_by_approvals_spec.rb" -- "./spec/features/merge_requests/user_filters_by_assignees_spec.rb" -- "./spec/features/merge_requests/user_filters_by_deployments_spec.rb" -- "./spec/features/merge_requests/user_filters_by_draft_spec.rb" -- "./spec/features/merge_requests/user_filters_by_labels_spec.rb" -- "./spec/features/merge_requests/user_filters_by_milestones_spec.rb" -- "./spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb" -- "./spec/features/merge_requests/user_filters_by_target_branch_spec.rb" -- "./spec/features/merge_requests/user_mass_updates_spec.rb" -- "./spec/features/merge_request/user_accepts_merge_request_spec.rb" -- "./spec/features/merge_request/user_allows_commits_from_memebers_who_can_merge_spec.rb" -- "./spec/features/merge_request/user_approves_spec.rb" -- "./spec/features/merge_request/user_assigns_themselves_spec.rb" -- "./spec/features/merge_request/user_awards_emoji_spec.rb" -- "./spec/features/merge_request/user_clicks_merge_request_tabs_spec.rb" -- "./spec/features/merge_request/user_comments_on_commit_spec.rb" -- "./spec/features/merge_request/user_comments_on_diff_spec.rb" -- "./spec/features/merge_request/user_comments_on_merge_request_spec.rb" -- "./spec/features/merge_request/user_creates_image_diff_notes_spec.rb" -- "./spec/features/merge_request/user_creates_merge_request_spec.rb" -- "./spec/features/merge_request/user_creates_mr_spec.rb" -- "./spec/features/merge_request/user_customizes_merge_commit_message_spec.rb" -- "./spec/features/merge_request/user_edits_assignees_sidebar_spec.rb" -- "./spec/features/merge_request/user_edits_merge_request_spec.rb" -- "./spec/features/merge_request/user_edits_mr_spec.rb" -- "./spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb" -- "./spec/features/merge_request/user_expands_diff_spec.rb" -- "./spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb" -- "./spec/features/merge_request/user_invites_from_a_comment_spec.rb" -- "./spec/features/merge_request/user_jumps_to_discussion_spec.rb" -- "./spec/features/merge_request/user_locks_discussion_spec.rb" -- "./spec/features/merge_request/user_manages_subscription_spec.rb" -- "./spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb" -- "./spec/features/merge_request/user_merges_immediately_spec.rb" -- "./spec/features/merge_request/user_merges_merge_request_spec.rb" -- "./spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb" -- "./spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb" -- "./spec/features/merge_request/user_posts_diff_notes_spec.rb" -- "./spec/features/merge_request/user_posts_notes_spec.rb" -- "./spec/features/merge_request/user_rebases_merge_request_spec.rb" -- "./spec/features/merge_request/user_resolves_conflicts_spec.rb" -- "./spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb" -- "./spec/features/merge_request/user_resolves_outdated_diff_discussions_spec.rb" -- "./spec/features/merge_request/user_resolves_wip_mr_spec.rb" -- "./spec/features/merge_request/user_reverts_merge_request_spec.rb" -- "./spec/features/merge_request/user_reviews_image_spec.rb" -- "./spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb" -- "./spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb" -- "./spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb" -- "./spec/features/merge_request/user_sees_cherry_pick_modal_spec.rb" -- "./spec/features/merge_request/user_sees_closing_issues_message_spec.rb" -- "./spec/features/merge_request/user_sees_deleted_target_branch_spec.rb" -- "./spec/features/merge_request/user_sees_deployment_widget_spec.rb" -- "./spec/features/merge_request/user_sees_diff_spec.rb" -- "./spec/features/merge_request/user_sees_discussions_spec.rb" -- "./spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb" -- "./spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb" -- "./spec/features/merge_request/user_sees_merge_widget_spec.rb" -- "./spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb" -- "./spec/features/merge_request/user_sees_mr_from_deleted_forked_project_spec.rb" -- "./spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb" -- "./spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb" -- "./spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb" -- "./spec/features/merge_request/user_sees_pipelines_spec.rb" -- "./spec/features/merge_request/user_sees_suggest_pipeline_spec.rb" -- "./spec/features/merge_request/user_sees_system_notes_spec.rb" -- "./spec/features/merge_request/user_sees_versions_spec.rb" -- "./spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb" -- "./spec/features/merge_request/user_squashes_merge_request_spec.rb" -- "./spec/features/merge_request/user_suggests_changes_on_diff_spec.rb" -- "./spec/features/merge_request/user_toggles_whitespace_changes_spec.rb" -- "./spec/features/merge_request/user_uses_quick_actions_spec.rb" -- "./spec/features/merge_request/user_views_auto_expanding_diff_spec.rb" -- "./spec/features/merge_request/user_views_diffs_commit_spec.rb" -- "./spec/features/merge_request/user_views_diffs_file_by_file_spec.rb" -- "./spec/features/merge_request/user_views_diffs_spec.rb" -- "./spec/features/merge_request/user_views_open_merge_request_spec.rb" -- "./spec/features/merge_request/user_views_user_status_on_merge_request_spec.rb" -- "./spec/features/milestone_spec.rb" -- "./spec/features/milestones/user_creates_milestone_spec.rb" -- "./spec/features/milestones/user_deletes_milestone_spec.rb" -- "./spec/features/milestones/user_edits_milestone_spec.rb" -- "./spec/features/milestones/user_views_milestone_spec.rb" -- "./spec/features/milestones/user_views_milestones_spec.rb" -- "./spec/features/nav/top_nav_responsive_spec.rb" -- "./spec/features/oauth_login_spec.rb" -- "./spec/features/participants_autocomplete_spec.rb" -- "./spec/features/populate_new_pipeline_vars_with_params_spec.rb" -- "./spec/features/profiles/account_spec.rb" -- "./spec/features/profiles/active_sessions_spec.rb" -- "./spec/features/profiles/keys_spec.rb" -- "./spec/features/profiles/oauth_applications_spec.rb" -- "./spec/features/profile_spec.rb" -- "./spec/features/profiles/personal_access_tokens_spec.rb" -- "./spec/features/profiles/user_changes_notified_of_own_activity_spec.rb" -- "./spec/features/profiles/user_edit_preferences_spec.rb" -- "./spec/features/profiles/user_edit_profile_spec.rb" -- "./spec/features/profiles/user_search_settings_spec.rb" -- "./spec/features/profiles/user_visits_notifications_tab_spec.rb" -- "./spec/features/profiles/user_visits_profile_preferences_page_spec.rb" -- "./spec/features/profiles/user_visits_profile_spec.rb" -- "./spec/features/project_group_variables_spec.rb" -- "./spec/features/projects/activity/user_sees_activity_spec.rb" -- "./spec/features/projects/activity/user_sees_design_activity_spec.rb" -- "./spec/features/projects/activity/user_sees_design_comment_spec.rb" -- "./spec/features/projects/activity/user_sees_private_activity_spec.rb" -- "./spec/features/projects/artifacts/file_spec.rb" -- "./spec/features/projects/artifacts/raw_spec.rb" -- "./spec/features/projects/artifacts/user_browses_artifacts_spec.rb" -- "./spec/features/projects/badges/list_spec.rb" -- "./spec/features/projects/badges/pipeline_badge_spec.rb" -- "./spec/features/projects/blobs/balsamiq_spec.rb" -- "./spec/features/projects/blobs/blob_line_permalink_updater_spec.rb" -- "./spec/features/projects/blobs/blob_show_spec.rb" -- "./spec/features/projects/blobs/edit_spec.rb" -- "./spec/features/projects/blobs/shortcuts_blob_spec.rb" -- "./spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb" -- "./spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb" -- "./spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb" -- "./spec/features/projects/branches/new_branch_ref_dropdown_spec.rb" -- "./spec/features/projects/branches_spec.rb" -- "./spec/features/projects/branches/user_creates_branch_spec.rb" -- "./spec/features/projects/branches/user_deletes_branch_spec.rb" -- "./spec/features/projects/branches/user_views_branches_spec.rb" -- "./spec/features/projects/ci/editor_spec.rb" -- "./spec/features/projects/clusters/eks_spec.rb" -- "./spec/features/projects/clusters/gcp_spec.rb" -- "./spec/features/projects/clusters_spec.rb" -- "./spec/features/projects/clusters/user_spec.rb" -- "./spec/features/projects/commit/builds_spec.rb" -- "./spec/features/projects/commit/cherry_pick_spec.rb" -- "./spec/features/projects/commit/comments/user_adds_comment_spec.rb" -- "./spec/features/projects/commit/comments/user_deletes_comments_spec.rb" -- "./spec/features/projects/commit/comments/user_edits_comments_spec.rb" -- "./spec/features/projects/commit/diff_notes_spec.rb" -- "./spec/features/projects/commit/mini_pipeline_graph_spec.rb" -- "./spec/features/projects/commits/user_browses_commits_spec.rb" -- "./spec/features/projects/commit/user_comments_on_commit_spec.rb" -- "./spec/features/projects/commit/user_reverts_commit_spec.rb" -- "./spec/features/projects/commit/user_views_user_status_on_commit_spec.rb" -- "./spec/features/projects/compare_spec.rb" -- "./spec/features/projects/container_registry_spec.rb" -- "./spec/features/projects/deploy_keys_spec.rb" -- "./spec/features/projects/diffs/diff_show_spec.rb" -- "./spec/features/projects/environments/environment_metrics_spec.rb" -- "./spec/features/projects/environments/environment_spec.rb" -- "./spec/features/projects/environments/environments_spec.rb" -- "./spec/features/projects/environments_pod_logs_spec.rb" -- "./spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb" -- "./spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb" -- "./spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb" -- "./spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb" -- "./spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb" -- "./spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb" -- "./spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb" -- "./spec/features/projects/features_visibility_spec.rb" -- "./spec/features/projects/files/dockerfile_dropdown_spec.rb" -- "./spec/features/projects/files/edit_file_soft_wrap_spec.rb" -- "./spec/features/projects/files/files_sort_submodules_with_folders_spec.rb" -- "./spec/features/projects/files/find_file_keyboard_spec.rb" -- "./spec/features/projects/files/gitignore_dropdown_spec.rb" -- "./spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb" -- "./spec/features/projects/files/project_owner_creates_license_file_spec.rb" -- "./spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb" -- "./spec/features/projects/files/template_selector_menu_spec.rb" -- "./spec/features/projects/files/template_type_dropdown_spec.rb" -- "./spec/features/projects/files/undo_template_spec.rb" -- "./spec/features/projects/files/user_browses_a_tree_with_a_folder_containing_only_a_folder_spec.rb" -- "./spec/features/projects/files/user_browses_files_spec.rb" -- "./spec/features/projects/files/user_browses_lfs_files_spec.rb" -- "./spec/features/projects/files/user_creates_directory_spec.rb" -- "./spec/features/projects/files/user_creates_files_spec.rb" -- "./spec/features/projects/files/user_deletes_files_spec.rb" -- "./spec/features/projects/files/user_edits_files_spec.rb" -- "./spec/features/projects/files/user_find_file_spec.rb" -- "./spec/features/projects/files/user_reads_pipeline_status_spec.rb" -- "./spec/features/projects/files/user_replaces_files_spec.rb" -- "./spec/features/projects/files/user_uploads_files_spec.rb" -- "./spec/features/projects/fork_spec.rb" -- "./spec/features/projects/gfm_autocomplete_load_spec.rb" -- "./spec/features/projects/graph_spec.rb" -- "./spec/features/projects/import_export/export_file_spec.rb" -- "./spec/features/projects/import_export/import_file_spec.rb" -- "./spec/features/projects/infrastructure_registry_spec.rb" -- "./spec/features/projects/integrations/user_activates_asana_spec.rb" -- "./spec/features/projects/integrations/user_activates_assembla_spec.rb" -- "./spec/features/projects/integrations/user_activates_atlassian_bamboo_ci_spec.rb" -- "./spec/features/projects/integrations/user_activates_flowdock_spec.rb" -- "./spec/features/projects/integrations/user_activates_jira_spec.rb" -- "./spec/features/projects/integrations/user_activates_pivotaltracker_spec.rb" -- "./spec/features/projects/integrations/user_uses_inherited_settings_spec.rb" -- "./spec/features/projects/issuable_templates_spec.rb" -- "./spec/features/projects/issues/design_management/user_paginates_designs_spec.rb" -- "./spec/features/projects/issues/design_management/user_permissions_upload_spec.rb" -- "./spec/features/projects/issues/design_management/user_uploads_designs_spec.rb" -- "./spec/features/projects/issues/design_management/user_views_design_spec.rb" -- "./spec/features/projects/issues/design_management/user_views_designs_spec.rb" -- "./spec/features/projects/issues/design_management/user_views_designs_with_svg_xss_spec.rb" -- "./spec/features/projects/issues/email_participants_spec.rb" -- "./spec/features/projects/jobs/permissions_spec.rb" -- "./spec/features/projects/jobs_spec.rb" -- "./spec/features/projects/jobs/user_browses_job_spec.rb" -- "./spec/features/projects/jobs/user_browses_jobs_spec.rb" -- "./spec/features/projects/labels/issues_sorted_by_priority_spec.rb" -- "./spec/features/projects/labels/search_labels_spec.rb" -- "./spec/features/projects/labels/sort_labels_spec.rb" -- "./spec/features/projects/labels/subscription_spec.rb" -- "./spec/features/projects/labels/update_prioritization_spec.rb" -- "./spec/features/projects/labels/user_removes_labels_spec.rb" -- "./spec/features/projects/members/anonymous_user_sees_members_spec.rb" -- "./spec/features/projects/members/group_member_cannot_leave_group_project_spec.rb" -- "./spec/features/projects/members/group_members_spec.rb" -- "./spec/features/projects/members/group_requester_cannot_request_access_to_project_spec.rb" -- "./spec/features/projects/members/groups_with_access_list_spec.rb" -- "./spec/features/projects/members/invite_group_spec.rb" -- "./spec/features/projects/members/list_spec.rb" -- "./spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb" -- "./spec/features/projects/members/master_manages_access_requests_spec.rb" -- "./spec/features/projects/members/sorting_spec.rb" -- "./spec/features/projects/members/tabs_spec.rb" -- "./spec/features/projects/members/user_requests_access_spec.rb" -- "./spec/features/projects/merge_request_button_spec.rb" -- "./spec/features/projects/milestones/gfm_autocomplete_spec.rb" -- "./spec/features/projects/milestones/milestones_sorting_spec.rb" -- "./spec/features/projects/milestones/new_spec.rb" -- "./spec/features/projects/milestones/user_interacts_with_labels_spec.rb" -- "./spec/features/projects/network_graph_spec.rb" -- "./spec/features/projects/new_project_from_template_spec.rb" -- "./spec/features/projects/new_project_spec.rb" -- "./spec/features/projects/packages_spec.rb" -- "./spec/features/projects/pages/user_adds_domain_spec.rb" -- "./spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb" -- "./spec/features/projects/pages/user_edits_settings_spec.rb" -- "./spec/features/projects/pipeline_schedules_spec.rb" - "./spec/features/projects/pipelines/pipeline_spec.rb" -- "./spec/features/projects/pipelines/pipelines_spec.rb" -- "./spec/features/projects/product_analytics/graphs_spec.rb" -- "./spec/features/projects/releases/user_creates_release_spec.rb" -- "./spec/features/projects/releases/user_views_edit_release_spec.rb" -- "./spec/features/projects/releases/user_views_release_spec.rb" -- "./spec/features/projects/releases/user_views_releases_spec.rb" -- "./spec/features/projects/remote_mirror_spec.rb" -- "./spec/features/projects/serverless/functions_spec.rb" -- "./spec/features/projects/services/disable_triggers_spec.rb" -- "./spec/features/projects/services/prometheus_external_alerts_spec.rb" -- "./spec/features/projects/services/user_activates_emails_on_push_spec.rb" -- "./spec/features/projects/services/user_activates_irker_spec.rb" -- "./spec/features/projects/services/user_activates_issue_tracker_spec.rb" -- "./spec/features/projects/services/user_activates_jetbrains_teamcity_ci_spec.rb" -- "./spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb" -- "./spec/features/projects/services/user_activates_packagist_spec.rb" -- "./spec/features/projects/services/user_activates_prometheus_spec.rb" -- "./spec/features/projects/services/user_activates_pushover_spec.rb" -- "./spec/features/projects/services/user_activates_slack_notifications_spec.rb" -- "./spec/features/projects/services/user_activates_slack_slash_command_spec.rb" -- "./spec/features/projects/services/user_views_services_spec.rb" -- "./spec/features/projects/settings/access_tokens_spec.rb" -- "./spec/features/projects/settings/lfs_settings_spec.rb" -- "./spec/features/projects/settings/monitor_settings_spec.rb" -- "./spec/features/projects/settings/packages_settings_spec.rb" -- "./spec/features/projects/settings/project_badges_spec.rb" -- "./spec/features/projects/settings/project_settings_spec.rb" -- "./spec/features/projects/settings/registry_settings_spec.rb" -- "./spec/features/projects/settings/repository_settings_spec.rb" -- "./spec/features/projects/settings/service_desk_setting_spec.rb" -- "./spec/features/projects/settings/user_changes_default_branch_spec.rb" -- "./spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb" -- "./spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb" -- "./spec/features/projects/settings/user_manages_project_members_spec.rb" -- "./spec/features/projects/settings/user_searches_in_settings_spec.rb" -- "./spec/features/projects/settings/user_sees_revoke_deploy_token_modal_spec.rb" -- "./spec/features/projects/settings/user_tags_project_spec.rb" -- "./spec/features/projects/settings/user_transfers_a_project_spec.rb" -- "./spec/features/projects/settings/visibility_settings_spec.rb" -- "./spec/features/projects/settings/webhooks_settings_spec.rb" -- "./spec/features/projects/show/schema_markup_spec.rb" -- "./spec/features/projects/show/user_interacts_with_auto_devops_banner_spec.rb" -- "./spec/features/projects/show/user_interacts_with_stars_spec.rb" -- "./spec/features/projects/show/user_manages_notifications_spec.rb" -- "./spec/features/projects/show/user_sees_collaboration_links_spec.rb" -- "./spec/features/projects/show/user_sees_last_commit_ci_status_spec.rb" -- "./spec/features/projects/show/user_sees_readme_spec.rb" -- "./spec/features/projects/show/user_uploads_files_spec.rb" -- "./spec/features/projects/snippets/create_snippet_spec.rb" -- "./spec/features/projects/snippets/show_spec.rb" -- "./spec/features/projects/snippets/user_comments_on_snippet_spec.rb" -- "./spec/features/projects/snippets/user_deletes_snippet_spec.rb" -- "./spec/features/projects/snippets/user_updates_snippet_spec.rb" -- "./spec/features/projects_spec.rb" -- "./spec/features/projects/sub_group_issuables_spec.rb" -- "./spec/features/projects/tags/user_edits_tags_spec.rb" -- "./spec/features/projects/terraform_spec.rb" -- "./spec/features/projects/tree/create_directory_spec.rb" -- "./spec/features/projects/tree/create_file_spec.rb" -- "./spec/features/projects/tree/tree_show_spec.rb" -- "./spec/features/projects/tree/upload_file_spec.rb" -- "./spec/features/projects/user_changes_project_visibility_spec.rb" -- "./spec/features/projects/user_creates_project_spec.rb" -- "./spec/features/projects/user_sees_sidebar_spec.rb" -- "./spec/features/projects/user_sees_user_popover_spec.rb" -- "./spec/features/projects/user_uses_shortcuts_spec.rb" -- "./spec/features/projects/user_views_empty_project_spec.rb" -- "./spec/features/projects/view_on_env_spec.rb" -- "./spec/features/projects/wikis_spec.rb" -- "./spec/features/projects/wiki/user_views_wiki_empty_spec.rb" -- "./spec/features/project_variables_spec.rb" -- "./spec/features/promotion_spec.rb" -- "./spec/features/protected_branches_spec.rb" -- "./spec/features/protected_tags_spec.rb" -- "./spec/features/reportable_note/commit_spec.rb" -- "./spec/features/reportable_note/issue_spec.rb" -- "./spec/features/reportable_note/merge_request_spec.rb" -- "./spec/features/reportable_note/snippets_spec.rb" -- "./spec/features/runners_spec.rb" -- "./spec/features/search/user_searches_for_code_spec.rb" -- "./spec/features/search/user_searches_for_commits_spec.rb" -- "./spec/features/search/user_searches_for_issues_spec.rb" -- "./spec/features/search/user_searches_for_merge_requests_spec.rb" -- "./spec/features/search/user_searches_for_milestones_spec.rb" -- "./spec/features/search/user_searches_for_projects_spec.rb" -- "./spec/features/search/user_searches_for_users_spec.rb" -- "./spec/features/search/user_searches_for_wiki_pages_spec.rb" -- "./spec/features/search/user_uses_header_search_field_spec.rb" -- "./spec/features/search/user_uses_search_filters_spec.rb" - "./spec/features/signed_commits_spec.rb" -- "./spec/features/snippets/embedded_snippet_spec.rb" -- "./spec/features/snippets/internal_snippet_spec.rb" -- "./spec/features/snippets/notes_on_personal_snippets_spec.rb" -- "./spec/features/snippets/private_snippets_spec.rb" -- "./spec/features/snippets/public_snippets_spec.rb" -- "./spec/features/snippets/show_spec.rb" -- "./spec/features/snippets/user_creates_snippet_spec.rb" -- "./spec/features/snippets/user_deletes_snippet_spec.rb" -- "./spec/features/snippets/user_edits_snippet_spec.rb" -- "./spec/features/tags/developer_creates_tag_spec.rb" -- "./spec/features/tags/developer_deletes_tag_spec.rb" -- "./spec/features/tags/developer_updates_tag_spec.rb" -- "./spec/features/task_lists_spec.rb" -- "./spec/features/triggers_spec.rb" -- "./spec/features/u2f_spec.rb" -- "./spec/features/uploads/user_uploads_avatar_to_profile_spec.rb" -- "./spec/features/uploads/user_uploads_file_to_note_spec.rb" -- "./spec/features/user_can_display_performance_bar_spec.rb" -- "./spec/features/user_opens_link_to_comment_spec.rb" -- "./spec/features/user_sees_revert_modal_spec.rb" -- "./spec/features/users/login_spec.rb" -- "./spec/features/users/logout_spec.rb" -- "./spec/features/users/overview_spec.rb" -- "./spec/features/users/signup_spec.rb" -- "./spec/features/users/snippets_spec.rb" -- "./spec/features/users/terms_spec.rb" -- "./spec/features/users/user_browses_projects_on_user_page_spec.rb" -- "./spec/features/webauthn_spec.rb" -- "./spec/features/whats_new_spec.rb" -- "./spec/finders/ci/pipeline_schedules_finder_spec.rb" -- "./spec/finders/ci/pipelines_finder_spec.rb" -- "./spec/finders/ci/pipelines_for_merge_request_finder_spec.rb" -- "./spec/finders/projects_finder_spec.rb" -- "./spec/finders/releases/evidence_pipeline_finder_spec.rb" -- "./spec/frontend/fixtures/analytics.rb" -- "./spec/frontend/fixtures/jobs.rb" -- "./spec/frontend/fixtures/pipeline_schedules.rb" -- "./spec/frontend/fixtures/pipelines.rb" -- "./spec/graphql/mutations/design_management/upload_spec.rb" -- "./spec/graphql/mutations/merge_requests/accept_spec.rb" -- "./spec/graphql/resolvers/ci/test_report_summary_resolver_spec.rb" - "./spec/helpers/issuables_helper_spec.rb" -- "./spec/initializers/active_record_locking_spec.rb" -- "./spec/initializers/database_config_spec.rb" - "./spec/lib/gitlab/auth_spec.rb" -- "./spec/lib/gitlab/ci/badge/pipeline/status_spec.rb" -- "./spec/lib/gitlab/ci/build/policy/changes_spec.rb" -- "./spec/lib/gitlab/ci/charts_spec.rb" -- "./spec/lib/gitlab/ci/config_spec.rb" - "./spec/lib/gitlab/ci/pipeline/chain/create_spec.rb" - "./spec/lib/gitlab/ci/pipeline/chain/seed_block_spec.rb" - "./spec/lib/gitlab/ci/pipeline/seed/build_spec.rb" -- "./spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb" -- "./spec/lib/gitlab/ci/status/stage/common_spec.rb" -- "./spec/lib/gitlab/ci/status/stage/factory_spec.rb" -- "./spec/lib/gitlab/ci/status/stage/play_manual_spec.rb" - "./spec/lib/gitlab/ci/templates/5_minute_production_app_ci_yaml_spec.rb" -- "./spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb" - "./spec/lib/gitlab/ci/templates/AWS/deploy_ecs_gitlab_ci_yaml_spec.rb" - "./spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb" +- "./spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb" - "./spec/lib/gitlab/ci/templates/managed_cluster_applications_gitlab_ci_yaml_spec.rb" -- "./spec/lib/gitlab/database/bulk_update_spec.rb" -- "./spec/lib/gitlab/database/connection_spec.rb" -- "./spec/lib/gitlab/database/load_balancing/host_spec.rb" -- "./spec/lib/gitlab/database/load_balancing_spec.rb" -- "./spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb" -- "./spec/lib/gitlab/database/postgresql_adapter/type_map_cache_spec.rb" -- "./spec/lib/gitlab/database/schema_migrations/context_spec.rb" -- "./spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb" -- "./spec/lib/gitlab/database/with_lock_retries_spec.rb" -- "./spec/lib/gitlab/data_builder/pipeline_spec.rb" - "./spec/lib/gitlab/email/handler/create_issue_handler_spec.rb" - "./spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb" - "./spec/lib/gitlab/email/handler/create_note_handler_spec.rb" - "./spec/lib/gitlab/email/handler/create_note_on_issuable_handler_spec.rb" -- "./spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb" -- "./spec/lib/gitlab/usage_data_spec.rb" - "./spec/lib/peek/views/active_record_spec.rb" -- "./spec/mailers/emails/pipelines_spec.rb" -- "./spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb" -- "./spec/migrations/20210722150102_operations_feature_flags_correct_flexible_rollout_values_spec.rb" -- "./spec/migrations/backfill_escalation_policies_for_oncall_schedules_spec.rb" -- "./spec/migrations/insert_ci_daily_pipeline_schedule_triggers_plan_limits_spec.rb" -- "./spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb" -- "./spec/models/ci/bridge_spec.rb" - "./spec/models/ci/build_need_spec.rb" -- "./spec/models/ci/build_spec.rb" - "./spec/models/ci/build_trace_chunk_spec.rb" -- "./spec/models/ci/commit_with_pipeline_spec.rb" -- "./spec/models/ci/group_spec.rb" - "./spec/models/ci/group_variable_spec.rb" -- "./spec/models/ci/instance_variable_spec.rb" - "./spec/models/ci/job_artifact_spec.rb" - "./spec/models/ci/job_variable_spec.rb" -- "./spec/models/ci/legacy_stage_spec.rb" -- "./spec/models/ci/pipeline_schedule_spec.rb" - "./spec/models/ci/pipeline_spec.rb" -- "./spec/models/ci/runner_namespace_spec.rb" -- "./spec/models/ci/runner_project_spec.rb" - "./spec/models/ci/runner_spec.rb" -- "./spec/models/ci/running_build_spec.rb" -- "./spec/models/ci/stage_spec.rb" - "./spec/models/ci/variable_spec.rb" -- "./spec/models/clusters/applications/jupyter_spec.rb" - "./spec/models/clusters/applications/runner_spec.rb" -- "./spec/models/commit_collection_spec.rb" - "./spec/models/commit_status_spec.rb" - "./spec/models/concerns/batch_destroy_dependent_associations_spec.rb" - "./spec/models/concerns/bulk_insertable_associations_spec.rb" -- "./spec/models/concerns/cron_schedulable_spec.rb" - "./spec/models/concerns/has_environment_scope_spec.rb" -- "./spec/models/concerns/schedulable_spec.rb" - "./spec/models/concerns/token_authenticatable_spec.rb" - "./spec/models/design_management/version_spec.rb" -- "./spec/models/environment_status_spec.rb" - "./spec/models/hooks/system_hook_spec.rb" -- "./spec/models/issue_spec.rb" - "./spec/models/members/project_member_spec.rb" -- "./spec/models/merge_request_spec.rb" -- "./spec/models/plan_spec.rb" -- "./spec/models/project_feature_usage_spec.rb" -- "./spec/models/project_spec.rb" - "./spec/models/spam_log_spec.rb" - "./spec/models/user_spec.rb" - "./spec/models/user_status_spec.rb" -- "./spec/policies/ci/build_policy_spec.rb" -- "./spec/policies/ci/pipeline_policy_spec.rb" -- "./spec/presenters/ci/stage_presenter_spec.rb" -- "./spec/requests/api/admin/ci/variables_spec.rb" -- "./spec/requests/api/admin/plan_limits_spec.rb" -- "./spec/requests/api/ci/jobs_spec.rb" - "./spec/requests/api/ci/pipeline_schedules_spec.rb" - "./spec/requests/api/ci/pipelines_spec.rb" -- "./spec/requests/api/ci/runner/runners_post_spec.rb" -- "./spec/requests/api/ci/runners_spec.rb" -- "./spec/requests/api/commits_spec.rb" - "./spec/requests/api/commit_statuses_spec.rb" -- "./spec/requests/api/graphql/ci/runner_spec.rb" +- "./spec/requests/api/commits_spec.rb" - "./spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb" -- "./spec/requests/api/graphql/project/issues_spec.rb" -- "./spec/requests/api/graphql/project/merge_request_spec.rb" -- "./spec/requests/api/graphql/project_query_spec.rb" -- "./spec/requests/api/issues/issues_spec.rb" -- "./spec/requests/api/merge_requests_spec.rb" -- "./spec/requests/api/projects_spec.rb" - "./spec/requests/api/resource_access_tokens_spec.rb" - "./spec/requests/api/users_spec.rb" -- "./spec/requests/lfs_http_spec.rb" -- "./spec/requests/projects/cycle_analytics_events_spec.rb" -- "./spec/serializers/ci/downloadable_artifact_entity_spec.rb" -- "./spec/serializers/ci/downloadable_artifact_serializer_spec.rb" -- "./spec/serializers/ci/pipeline_entity_spec.rb" -- "./spec/serializers/merge_request_poll_cached_widget_entity_spec.rb" -- "./spec/serializers/merge_request_poll_widget_entity_spec.rb" -- "./spec/serializers/merge_request_widget_entity_spec.rb" -- "./spec/serializers/pipeline_details_entity_spec.rb" -- "./spec/serializers/pipeline_serializer_spec.rb" -- "./spec/serializers/stage_entity_spec.rb" -- "./spec/serializers/stage_serializer_spec.rb" -- "./spec/serializers/test_report_entity_spec.rb" -- "./spec/serializers/test_report_summary_entity_spec.rb" -- "./spec/serializers/test_suite_entity_spec.rb" -- "./spec/serializers/test_suite_summary_entity_spec.rb" -- "./spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb" -- "./spec/services/ci/compare_accessibility_reports_service_spec.rb" -- "./spec/services/ci/compare_codequality_reports_service_spec.rb" -- "./spec/services/ci/compare_reports_base_service_spec.rb" -- "./spec/services/ci/compare_test_reports_service_spec.rb" - "./spec/services/ci/create_pipeline_service/environment_spec.rb" - "./spec/services/ci/create_pipeline_service_spec.rb" - "./spec/services/ci/destroy_pipeline_service_spec.rb" -- "./spec/services/ci/disable_user_pipeline_schedules_service_spec.rb" - "./spec/services/ci/ensure_stage_service_spec.rb" - "./spec/services/ci/expire_pipeline_cache_service_spec.rb" -- "./spec/services/ci/generate_codequality_mr_diff_report_service_spec.rb" -- "./spec/services/ci/generate_coverage_reports_service_spec.rb" - "./spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb" - "./spec/services/ci/job_artifacts/destroy_associations_service_spec.rb" -- "./spec/services/ci/job_artifacts/destroy_batch_service_spec.rb" -- "./spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb" -- "./spec/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service_spec.rb" - "./spec/services/ci/pipeline_bridge_status_service_spec.rb" -- "./spec/services/ci/pipeline_processing/shared_processing_service.rb" - "./spec/services/ci/pipelines/add_job_service_spec.rb" -- "./spec/services/ci/pipeline_schedule_service_spec.rb" -- "./spec/services/ci/pipeline_trigger_service_spec.rb" -- "./spec/services/ci/register_job_service_spec.rb" - "./spec/services/ci/retry_build_service_spec.rb" -- "./spec/services/ci/test_failure_history_service_spec.rb" -- "./spec/services/ci/update_instance_variables_service_spec.rb" -- "./spec/services/deployments/update_environment_service_spec.rb" -- "./spec/services/design_management/save_designs_service_spec.rb" -- "./spec/services/environments/stop_service_spec.rb" - "./spec/services/groups/transfer_service_spec.rb" -- "./spec/services/integrations/test/project_service_spec.rb" -- "./spec/services/issuable/destroy_service_spec.rb" -- "./spec/services/issue_links/list_service_spec.rb" -- "./spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb" -- "./spec/services/merge_requests/mergeability_check_service_spec.rb" -- "./spec/services/merge_requests/post_merge_service_spec.rb" -- "./spec/services/merge_requests/refresh_service_spec.rb" -- "./spec/services/pages/migrate_from_legacy_storage_service_spec.rb" - "./spec/services/projects/destroy_service_spec.rb" +- "./spec/services/projects/overwrite_project_service_spec.rb" - "./spec/services/projects/transfer_service_spec.rb" -- "./spec/services/projects/update_service_spec.rb" -- "./spec/services/releases/create_service_spec.rb" - "./spec/services/resource_access_tokens/revoke_service_spec.rb" -- "./spec/services/todo_service_spec.rb" -- "./spec/services/users/activity_service_spec.rb" - "./spec/services/users/destroy_service_spec.rb" - "./spec/services/users/reject_service_spec.rb" -- "./spec/support/shared_contexts/email_shared_context.rb" -- "./spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb" -- "./spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb" -- "./spec/support/shared_examples/integrations/test_examples.rb" -- "./spec/support/shared_examples/models/atomic_internal_id_shared_examples.rb" -- "./spec/support/shared_examples/models/cluster_application_status_shared_examples.rb" -- "./spec/support/shared_examples/models/cluster_application_version_shared_examples.rb" -- "./spec/support/shared_examples/models/concerns/cron_schedulable_shared_examples.rb" -- "./spec/support/shared_examples/models/concerns/limitable_shared_examples.rb" -- "./spec/support/shared_examples/models/update_highest_role_shared_examples.rb" -- "./spec/support/shared_examples/models/update_project_statistics_shared_examples.rb" -- "./spec/support/shared_examples/models/with_uploads_shared_examples.rb" -- "./spec/support/shared_examples/requests/api/status_shared_examples.rb" -- "./spec/support/shared_examples/requests/lfs_http_shared_examples.rb" -- "./spec/support/shared_examples/services/destroy_label_links_shared_examples.rb" -- "./spec/support/shared_examples/services/issuable/destroy_service_shared_examples.rb" -- "./spec/support/shared_examples/services/notification_service_shared_examples.rb" -- "./spec/support/shared_examples/services/wiki_pages/create_service_shared_examples.rb" -- "./spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb" -- "./spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb" -- "./spec/support/shared_examples/workers/idempotency_shared_examples.rb" -- "./spec/views/projects/artifacts/_artifact.html.haml_spec.rb" -- "./spec/views/projects/commits/_commit.html.haml_spec.rb" -- "./spec/views/projects/jobs/_build.html.haml_spec.rb" -- "./spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb" -- "./spec/views/projects/merge_requests/creations/_new_submit.html.haml_spec.rb" -- "./spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb" -- "./spec/views/shared/runners/_runner_details.html.haml_spec.rb" -- "./spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb" -- "./spec/workers/ci/pipeline_artifacts/create_quality_report_worker_spec.rb" -- "./spec/workers/container_expiration_policy_worker_spec.rb" - "./spec/workers/merge_requests/create_pipeline_worker_spec.rb" -- "./spec/workers/pipeline_metrics_worker_spec.rb" -- "./spec/workers/pipeline_schedule_worker_spec.rb" -- "./spec/workers/releases/create_evidence_worker_spec.rb" - "./spec/workers/remove_expired_members_worker_spec.rb" - "./spec/workers/repository_cleanup_worker_spec.rb" -- "./spec/workers/stage_update_worker_spec.rb" -- "./spec/workers/stuck_merge_jobs_worker_spec.rb" -- "./ee/spec/requests/api/graphql/project/pipelines/dast_profile_spec.rb" -- "./spec/services/projects/overwrite_project_service_spec.rb" diff --git a/spec/support/database/cross-join-allowlist.yml b/spec/support/database/cross-join-allowlist.yml index c209d275fc8..19b1ce30d5f 100644 --- a/spec/support/database/cross-join-allowlist.yml +++ b/spec/support/database/cross-join-allowlist.yml @@ -1,58 +1,6 @@ -- "./ee/spec/features/ci/ci_minutes_spec.rb" -- "./ee/spec/features/merge_trains/two_merge_requests_on_train_spec.rb" -- "./ee/spec/features/merge_trains/user_adds_merge_request_to_merge_train_spec.rb" -- "./ee/spec/finders/ee/namespaces/projects_finder_spec.rb" -- "./ee/spec/graphql/ee/resolvers/namespace_projects_resolver_spec.rb" -- "./ee/spec/models/ci/minutes/project_monthly_usage_spec.rb" -- "./ee/spec/models/project_spec.rb" -- "./ee/spec/models/security/finding_spec.rb" -- "./ee/spec/models/security/scan_spec.rb" -- "./ee/spec/requests/api/ci/minutes_spec.rb" -- "./ee/spec/requests/api/graphql/ci/minutes/usage_spec.rb" -- "./ee/spec/requests/api/namespaces_spec.rb" -- "./ee/spec/services/ci/minutes/additional_packs/change_namespace_service_spec.rb" -- "./ee/spec/services/ci/minutes/additional_packs/create_service_spec.rb" -- "./ee/spec/services/ci/minutes/refresh_cached_data_service_spec.rb" -- "./spec/controllers/admin/runners_controller_spec.rb" -- "./spec/controllers/groups/settings/ci_cd_controller_spec.rb" -- "./spec/controllers/projects/settings/ci_cd_controller_spec.rb" -- "./spec/features/admin/admin_runners_spec.rb" -- "./spec/features/ide/user_opens_merge_request_spec.rb" -- "./spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb" -- "./spec/features/projects/infrastructure_registry_spec.rb" -- "./spec/finders/ci/pipelines_for_merge_request_finder_spec.rb" -- "./spec/finders/ci/runners_finder_spec.rb" -- "./spec/frontend/fixtures/runner.rb" -- "./spec/graphql/resolvers/ci/group_runners_resolver_spec.rb" -- "./spec/lib/api/entities/package_spec.rb" - "./spec/lib/gitlab/background_migration/copy_ci_builds_columns_to_security_scans_spec.rb" - "./spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb" - "./spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb" - "./spec/migrations/associate_existing_dast_builds_with_variables_spec.rb" +- "./spec/migrations/disable_job_token_scope_when_unused_spec.rb" - "./spec/migrations/schedule_copy_ci_builds_columns_to_security_scans2_spec.rb" -- "./spec/migrations/schedule_pages_metadata_migration_spec.rb" -- "./spec/models/ci/pipeline_spec.rb" -- "./spec/models/ci/runner_spec.rb" -- "./spec/models/merge_request_spec.rb" -- "./spec/models/project_spec.rb" -- "./spec/models/user_spec.rb" -- "./spec/presenters/packages/detail/package_presenter_spec.rb" -- "./spec/requests/api/ci/runner/runners_post_spec.rb" -- "./spec/requests/api/ci/runners_spec.rb" -- "./spec/requests/api/graphql/ci/runner_spec.rb" -- "./spec/requests/api/graphql/group_query_spec.rb" -- "./spec/requests/api/graphql/packages/composer_spec.rb" -- "./spec/requests/api/graphql/packages/conan_spec.rb" -- "./spec/requests/api/graphql/packages/maven_spec.rb" -- "./spec/requests/api/graphql/packages/nuget_spec.rb" -- "./spec/requests/api/graphql/packages/package_spec.rb" -- "./spec/requests/api/graphql/packages/pypi_spec.rb" -- "./spec/requests/api/package_files_spec.rb" -- "./spec/services/environments/stop_service_spec.rb" -- "./spec/services/merge_requests/post_merge_service_spec.rb" -- "./spec/support/shared_examples/features/packages_shared_examples.rb" -- "./spec/support/shared_examples/models/concerns/limitable_shared_examples.rb" -- "./spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb" -- "./spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb" -- "./spec/support/shared_examples/requests/graphql_shared_examples.rb" -- "./spec/support/shared_examples/services/packages_shared_examples.rb" diff --git a/spec/support/database/gitlab_schema.rb b/spec/support/database/gitlab_schema.rb deleted file mode 100644 index fe05fb998e6..00000000000 --- a/spec/support/database/gitlab_schema.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# This module gathes information about table to schema mapping -# to understand table affinity -module Database - module GitlabSchema - def self.table_schemas(tables) - tables.map { |table| table_schema(table) }.to_set - end - - def self.table_schema(name) - tables_to_schema[name] || :undefined - end - - def self.tables_to_schema - @tables_to_schema ||= all_classes_with_schema.to_h do |klass| - [klass.table_name, klass.gitlab_schema] - end - end - - def self.all_classes_with_schema - ActiveRecord::Base.descendants.reject(&:abstract_class?).select(&:gitlab_schema?) # rubocop:disable Database/MultipleDatabases - end - end -end diff --git a/spec/support/database/multiple_databases.rb b/spec/support/database/multiple_databases.rb index 5e1ae60536f..9e72ea589e3 100644 --- a/spec/support/database/multiple_databases.rb +++ b/spec/support/database/multiple_databases.rb @@ -6,6 +6,18 @@ module Database skip 'Skipping because multiple databases not set up' unless Gitlab::Database.has_config?(:ci) end + def reconfigure_db_connection(name: nil, config_hash: {}, model: ActiveRecord::Base, config_model: nil) + db_config = (config_model || model).connection_db_config + + new_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, + name ? name.to_s : db_config.name, + db_config.configuration_hash.merge(config_hash) + ) + + model.establish_connection(new_db_config) + end + # The usage of this method switches temporarily used `connection_handler` # allowing full manipulation of ActiveRecord::Base connections without # having side effects like: @@ -56,6 +68,21 @@ RSpec.configure do |config| example.run end end + + config.around(:each, :mocked_ci_connection) do |example| + with_reestablished_active_record_base(reconnect: true) do + reconfigure_db_connection( + name: :ci, + model: Ci::ApplicationRecord, + config_model: ActiveRecord::Base + ) + + example.run + + # Cleanup connection_specification_name for Ci::ApplicationRecord + Ci::ApplicationRecord.remove_connection + end + end end ActiveRecord::Base.singleton_class.prepend(::Database::ActiveRecordBaseEstablishConnection) # rubocop:disable Database/MultipleDatabases diff --git a/spec/support/database/prevent_cross_database_modification.rb b/spec/support/database/prevent_cross_database_modification.rb index 7ded85b65ce..c509aecf9b8 100644 --- a/spec/support/database/prevent_cross_database_modification.rb +++ b/spec/support/database/prevent_cross_database_modification.rb @@ -1,123 +1,31 @@ # frozen_string_literal: true -module Database - module PreventCrossDatabaseModification - CrossDatabaseModificationAcrossUnsupportedTablesError = Class.new(StandardError) - - module GitlabDatabaseMixin - def allow_cross_database_modification_within_transaction(url:) - cross_database_context = Database::PreventCrossDatabaseModification.cross_database_context - return yield unless cross_database_context && cross_database_context[:enabled] - - transaction_tracker_enabled_was = cross_database_context[:enabled] - cross_database_context[:enabled] = false - - yield - ensure - cross_database_context[:enabled] = transaction_tracker_enabled_was if cross_database_context - end - end - - module SpecHelpers - def with_cross_database_modification_prevented - subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |name, start, finish, id, payload| - PreventCrossDatabaseModification.prevent_cross_database_modification!(payload[:connection], payload[:sql]) - end - - PreventCrossDatabaseModification.reset_cross_database_context! - PreventCrossDatabaseModification.cross_database_context.merge!(enabled: true, subscriber: subscriber) - - yield if block_given? - ensure - cleanup_with_cross_database_modification_prevented if block_given? - end - - def cleanup_with_cross_database_modification_prevented - if PreventCrossDatabaseModification.cross_database_context - ActiveSupport::Notifications.unsubscribe(PreventCrossDatabaseModification.cross_database_context[:subscriber]) - PreventCrossDatabaseModification.cross_database_context[:enabled] = false - end - end - end - - def self.cross_database_context - Thread.current[:transaction_tracker] - end - - def self.reset_cross_database_context! - Thread.current[:transaction_tracker] = initial_data - end - - def self.initial_data - { - enabled: false, - transaction_depth_by_db: Hash.new { |h, k| h[k] = 0 }, - modified_tables_by_db: Hash.new { |h, k| h[k] = Set.new } - } - end - - def self.prevent_cross_database_modification!(connection, sql) - return unless cross_database_context - return unless cross_database_context[:enabled] - - return if connection.pool.instance_of?(ActiveRecord::ConnectionAdapters::NullPool) - - database = connection.pool.db_config.name - - if sql.start_with?('SAVEPOINT') - cross_database_context[:transaction_depth_by_db][database] += 1 - - return - elsif sql.start_with?('RELEASE SAVEPOINT', 'ROLLBACK TO SAVEPOINT') - cross_database_context[:transaction_depth_by_db][database] -= 1 - if cross_database_context[:transaction_depth_by_db][database] <= 0 - cross_database_context[:modified_tables_by_db][database].clear - end - - return - end - - return if cross_database_context[:transaction_depth_by_db].values.all?(&:zero?) - - # PgQuery might fail in some cases due to limited nesting: - # https://github.com/pganalyze/pg_query/issues/209 - parsed_query = PgQuery.parse(sql) - tables = sql.downcase.include?(' for update') ? parsed_query.tables : parsed_query.dml_tables - - return if tables.empty? - - cross_database_context[:modified_tables_by_db][database].merge(tables) - - all_tables = cross_database_context[:modified_tables_by_db].values.map(&:to_a).flatten - schemas = Database::GitlabSchema.table_schemas(all_tables) - - if schemas.many? - raise Database::PreventCrossDatabaseModification::CrossDatabaseModificationAcrossUnsupportedTablesError, - "Cross-database data modification of '#{schemas.to_a.join(", ")}' were detected within " \ - "a transaction modifying the '#{all_tables.to_a.join(", ")}' tables." \ - "Please refer to https://docs.gitlab.com/ee/development/database/multiple_databases.html#removing-cross-database-transactions for details on how to resolve this exception." - end - end - end +module PreventCrossDatabaseModificationSpecHelpers + delegate :with_cross_database_modification_prevented, + :allow_cross_database_modification_within_transaction, + to: :'::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification' end -Gitlab::Database.singleton_class.prepend( - Database::PreventCrossDatabaseModification::GitlabDatabaseMixin) - CROSS_DB_MODIFICATION_ALLOW_LIST = Set.new(YAML.load_file(File.join(__dir__, 'cross-database-modification-allowlist.yml'))).freeze RSpec.configure do |config| - config.include(::Database::PreventCrossDatabaseModification::SpecHelpers) + config.include(PreventCrossDatabaseModificationSpecHelpers) + + # By default allow cross-modifications as we want to observe only transactions + # within a specific block of execution which is defined be `before(:each)` and `after(:each)` + config.before(:all) do + ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.suppress = true + end # Using before and after blocks because the around block causes problems with the let_it_be # record creations. It makes an extra savepoint which breaks the transaction count logic. config.before do |example_file| - if CROSS_DB_MODIFICATION_ALLOW_LIST.exclude?(example_file.file_path) - with_cross_database_modification_prevented - end + ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.suppress = + CROSS_DB_MODIFICATION_ALLOW_LIST.include?(example_file.file_path_rerun_argument) end + # Reset after execution to preferred state config.after do |example_file| - cleanup_with_cross_database_modification_prevented + ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.suppress = true end end diff --git a/spec/support/database/prevent_cross_joins.rb b/spec/support/database/prevent_cross_joins.rb index f5ed2a8f22e..e69374fbc70 100644 --- a/spec/support/database/prevent_cross_joins.rb +++ b/spec/support/database/prevent_cross_joins.rb @@ -35,7 +35,7 @@ module Database # https://github.com/pganalyze/pg_query/issues/209 tables = PgQuery.parse(sql).tables - schemas = Database::GitlabSchema.table_schemas(tables) + schemas = ::Gitlab::Database::GitlabSchema.table_schemas(tables) if schemas.include?(:gitlab_ci) && schemas.include?(:gitlab_main) Thread.current[:has_cross_join_exception] = true @@ -96,7 +96,7 @@ RSpec.configure do |config| config.around do |example| Thread.current[:has_cross_join_exception] = false - if ALLOW_LIST.include?(example.file_path) + if ALLOW_LIST.include?(example.file_path_rerun_argument) example.run else with_cross_joins_prevented { example.run } diff --git a/spec/support/database/query_analyzer.rb b/spec/support/database/query_analyzer.rb new file mode 100644 index 00000000000..85fa55f81ef --- /dev/null +++ b/spec/support/database/query_analyzer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# With the usage of `describe '...', query_analyzers: false` +# can be disabled selectively + +RSpec.configure do |config| + config.around do |example| + if example.metadata.fetch(:query_analyzers, true) + ::Gitlab::Database::QueryAnalyzer.instance.within { example.run } + else + example.run + end + end +end diff --git a/spec/support/database_load_balancing.rb b/spec/support/database_load_balancing.rb index 014575e8a82..d2902ddcc7c 100644 --- a/spec/support/database_load_balancing.rb +++ b/spec/support/database_load_balancing.rb @@ -2,17 +2,17 @@ RSpec.configure do |config| config.around(:each, :database_replica) do |example| - old_proxies = [] + old_proxies = {} Gitlab::Database::LoadBalancing.base_models.each do |model| + old_proxies[model] = [model.load_balancer, model.connection, model.sticking] + config = Gitlab::Database::LoadBalancing::Configuration .new(model, [model.connection_db_config.configuration_hash[:host]]) - lb = Gitlab::Database::LoadBalancing::LoadBalancer.new(config) - - old_proxies << [model, model.connection] - model.connection = - Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb) + model.load_balancer = Gitlab::Database::LoadBalancing::LoadBalancer.new(config) + model.sticking = Gitlab::Database::LoadBalancing::Sticking.new(model.load_balancer) + model.connection = Gitlab::Database::LoadBalancing::ConnectionProxy.new(model.load_balancer) end Gitlab::Database::LoadBalancing::Session.clear_session @@ -23,8 +23,8 @@ RSpec.configure do |config| Gitlab::Database::LoadBalancing::Session.clear_session redis_shared_state_cleanup! - old_proxies.each do |(model, proxy)| - model.connection = proxy + old_proxies.each do |model, proxy| + model.load_balancer, model.connection, model.sticking = proxy end end end diff --git a/spec/support/flaky_tests.rb b/spec/support/flaky_tests.rb new file mode 100644 index 00000000000..30a064d8705 --- /dev/null +++ b/spec/support/flaky_tests.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +return unless ENV['CI'] +return unless ENV['SKIP_FLAKY_TESTS_AUTOMATICALLY'] == "true" +return if ENV['CI_MERGE_REQUEST_LABELS'].to_s.include?('pipeline:run-flaky-tests') + +require_relative '../../tooling/rspec_flaky/report' + +RSpec.configure do |config| + $flaky_test_example_ids = begin # rubocop:disable Style/GlobalVars + raise "$SUITE_FLAKY_RSPEC_REPORT_PATH is empty." if ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'].to_s.empty? + raise "#{ENV['SUITE_FLAKY_RSPEC_REPORT_PATH']} doesn't exist" unless File.exist?(ENV['SUITE_FLAKY_RSPEC_REPORT_PATH']) + + RspecFlaky::Report.load(ENV['SUITE_FLAKY_RSPEC_REPORT_PATH']).map { |_, flaky_test_data| flaky_test_data["example_id"] } + rescue => e # rubocop:disable Style/RescueStandardError + puts e + [] + end + $skipped_flaky_tests_report = [] # rubocop:disable Style/GlobalVars + + config.around do |example| + # Skip flaky tests automatically + if $flaky_test_example_ids.include?(example.id) # rubocop:disable Style/GlobalVars + puts "Skipping #{example.id} '#{example.full_description}' because it's flaky." + $skipped_flaky_tests_report << example.id # rubocop:disable Style/GlobalVars + else + example.run + end + end + + config.after(:suite) do + next unless ENV['SKIPPED_FLAKY_TESTS_REPORT_PATH'] + + File.write(ENV['SKIPPED_FLAKY_TESTS_REPORT_PATH'], "#{$skipped_flaky_tests_report.join("\n")}\n") # rubocop:disable Style/GlobalVars + end +end diff --git a/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb b/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb index de9735df546..4624a8ac82a 100644 --- a/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb +++ b/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb @@ -6,7 +6,9 @@ RSpec.shared_examples 'a correct instrumented metric value' do |params| let(:metric) { described_class.new(time_frame: time_frame, options: options) } before do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + if described_class.respond_to?(:relation) && described_class.relation.respond_to?(:connection) + allow(described_class.relation.connection).to receive(:transaction_open?).and_return(false) + end end it 'has correct value' do diff --git a/spec/support/graphql/fake_query_type.rb b/spec/support/graphql/fake_query_type.rb new file mode 100644 index 00000000000..ffd851a6e6a --- /dev/null +++ b/spec/support/graphql/fake_query_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Graphql + class FakeQueryType < Types::BaseObject + graphql_name 'FakeQuery' + + field :hello_world, String, null: true do + argument :message, String, required: false + end + + def hello_world(message: "world") + "Hello #{message}!" + end + end +end diff --git a/spec/support/graphql/fake_tracer.rb b/spec/support/graphql/fake_tracer.rb new file mode 100644 index 00000000000..c2fb7ed12d8 --- /dev/null +++ b/spec/support/graphql/fake_tracer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Graphql + class FakeTracer + def initialize(trace_callback) + @trace_callback = trace_callback + end + + def trace(*args) + @trace_callback.call(*args) + + yield + end + end +end diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb index 3ec52f8c832..722d484609c 100644 --- a/spec/support/helpers/cycle_analytics_helpers.rb +++ b/spec/support/helpers/cycle_analytics_helpers.rb @@ -63,6 +63,10 @@ module CycleAnalyticsHelpers wait_for_requests end + def click_save_value_stream_button + click_button(_('Save value stream')) + end + def create_custom_value_stream(custom_value_stream_name) toggle_value_stream_dropdown page.find_button(_('Create new Value Stream')).click diff --git a/spec/support/helpers/features/invite_members_modal_helper.rb b/spec/support/helpers/features/invite_members_modal_helper.rb index 69ba20c1ca4..3502558b2c2 100644 --- a/spec/support/helpers/features/invite_members_modal_helper.rb +++ b/spec/support/helpers/features/invite_members_modal_helper.rb @@ -8,7 +8,7 @@ module Spec def invite_member(name, role: 'Guest', expires_at: nil, area_of_focus: false) click_on 'Invite members' - page.within '#invite-members-modal' do + page.within '[data-testid="invite-members-modal"]' do find('[data-testid="members-token-select-input"]').set(name) wait_for_requests diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb index 5cfd03ecea8..8a329c2f9dd 100644 --- a/spec/support/helpers/gitaly_setup.rb +++ b/spec/support/helpers/gitaly_setup.rb @@ -98,7 +98,7 @@ module GitalySetup end def build_gitaly - system(env, 'make', chdir: tmp_tests_gitaly_dir) # rubocop:disable GitlabSecurity/SystemCommandInjection + system(env.merge({ 'GIT_VERSION' => nil }), 'make all git', chdir: tmp_tests_gitaly_dir) # rubocop:disable GitlabSecurity/SystemCommandInjection end def start_gitaly diff --git a/spec/support/helpers/gpg_helpers.rb b/spec/support/helpers/gpg_helpers.rb index 813c6176317..81e669aab57 100644 --- a/spec/support/helpers/gpg_helpers.rb +++ b/spec/support/helpers/gpg_helpers.rb @@ -4,6 +4,7 @@ module GpgHelpers SIGNED_COMMIT_SHA = '8a852d50dda17cc8fd1408d2fd0c5b0f24c76ca4' SIGNED_AND_AUTHORED_SHA = '3c1d9a0266cb0c62d926f4a6c649beed561846f5' DIFFERING_EMAIL_SHA = 'a17a9f66543673edf0a3d1c6b93bdda3fe600f32' + MULTIPLE_SIGNATURES_SHA = 'c7794c14268d67ad8a2d5f066d706539afc75a96' module User1 extend self diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 6f17d3cb496..ee4621deb2d 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -522,8 +522,7 @@ module GraphqlHelpers end end - # See note at graphql_data about memoization and multiple requests - def graphql_errors(body = json_response) + def graphql_errors(body = fresh_response_data) case body when Hash # regular query body['errors'] diff --git a/spec/support/helpers/migrations_helpers.rb b/spec/support/helpers/migrations_helpers.rb index 7799e49d4c1..0c5bf09f6b7 100644 --- a/spec/support/helpers/migrations_helpers.rb +++ b/spec/support/helpers/migrations_helpers.rb @@ -2,7 +2,7 @@ module MigrationsHelpers def active_record_base - ActiveRecord::Base + Gitlab::Database.database_base_models.fetch(self.class.metadata[:database] || :main) end def table(name) @@ -34,7 +34,7 @@ module MigrationsHelpers end def migrations_paths - ActiveRecord::Migrator.migrations_paths + active_record_base.connection.migrations_paths end def migration_context @@ -52,7 +52,7 @@ module MigrationsHelpers end def foreign_key_exists?(source, target = nil, column: nil) - ActiveRecord::Base.connection.foreign_keys(source).any? do |key| + active_record_base.connection.foreign_keys(source).any? do |key| if column key.options[:column].to_s == column.to_s else diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb index 96e79427278..c2ec82155cd 100644 --- a/spec/support/helpers/navbar_structure_helper.rb +++ b/spec/support/helpers/navbar_structure_helper.rb @@ -29,6 +29,19 @@ module NavbarStructureHelper ) end + def insert_customer_relations_nav(within) + insert_after_nav_item( + within, + new_nav_item: { + nav_item: _('Customer relations'), + nav_sub_items: [ + _('Contacts'), + _('Organizations') + ] + } + ) + end + def insert_container_nav insert_after_sub_nav_item( _('Package Registry'), diff --git a/spec/support/helpers/project_forks_helper.rb b/spec/support/helpers/project_forks_helper.rb index 4b4285f251e..84b5dbc1d23 100644 --- a/spec/support/helpers/project_forks_helper.rb +++ b/spec/support/helpers/project_forks_helper.rb @@ -28,11 +28,15 @@ module ProjectForksHelper unless params[:target_project] || params[:using_service] target_level = [project.visibility_level, namespace.visibility_level].min visibility_level = Gitlab::VisibilityLevel.closest_allowed_level(target_level) + # Builds and MRs can't have higher visibility level than repository access level. + builds_access_level = [project.builds_access_level, project.repository_access_level].min params[:target_project] = create(:project, (:repository if create_repository), - visibility_level: visibility_level, creator: user, namespace: namespace) + visibility_level: visibility_level, + builds_access_level: builds_access_level, + creator: user, namespace: namespace) end service = Projects::ForkService.new(project, user, params) diff --git a/spec/support/helpers/require_migration.rb b/spec/support/helpers/require_migration.rb index de3a8a81ab5..ee28f8e504c 100644 --- a/spec/support/helpers/require_migration.rb +++ b/spec/support/helpers/require_migration.rb @@ -15,7 +15,7 @@ class RequireMigration end MIGRATION_FOLDERS = %w[db/migrate db/post_migrate].freeze - SPEC_FILE_PATTERN = %r{.+/(?.+)_spec\.rb}.freeze + SPEC_FILE_PATTERN = %r{.+/(?:\d+_)?(?.+)_spec\.rb}.freeze class << self def require_migration!(file_name) @@ -26,10 +26,12 @@ class RequireMigration end def search_migration_file(file_name) + migration_file_pattern = /\A\d+_#{file_name}\.rb\z/ + migration_folders.flat_map do |path| migration_path = Rails.root.join(path).to_s - Find.find(migration_path).select { |m| File.basename(m).match? /\A\d+_#{file_name}\.rb\z/ } + Find.find(migration_path).select { |m| migration_file_pattern.match? File.basename(m) } end end diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb index 6f530d57caf..ef3c39c83c2 100644 --- a/spec/support/helpers/stub_gitlab_calls.rb +++ b/spec/support/helpers/stub_gitlab_calls.rb @@ -92,9 +92,16 @@ module StubGitlabCalls end def stub_commonmark_sourcepos_disabled + render_options = + if Feature.enabled?(:use_cmark_renderer) + Banzai::Filter::MarkdownEngines::CommonMark::RENDER_OPTIONS_C + else + Banzai::Filter::MarkdownEngines::CommonMark::RENDER_OPTIONS_RUBY + end + allow_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) .to receive(:render_options) - .and_return(Banzai::Filter::MarkdownEngines::CommonMark::RENDER_OPTIONS) + .and_return(render_options) end private diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index 56177d445d6..5e86b08aa45 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -4,7 +4,6 @@ module StubObjectStorage def stub_dependency_proxy_object_storage(**params) stub_object_storage_uploader(config: ::Gitlab.config.dependency_proxy.object_store, uploader: ::DependencyProxy::FileUploader, - remote_directory: 'dependency_proxy', **params) end @@ -16,7 +15,6 @@ module StubObjectStorage def stub_object_storage_uploader( config:, uploader:, - remote_directory:, enabled: true, proxy_download: false, background_upload: false, @@ -40,7 +38,7 @@ module StubObjectStorage return unless enabled stub_object_storage(connection_params: uploader.object_store_credentials, - remote_directory: remote_directory) + remote_directory: config.remote_directory) end def stub_object_storage(connection_params:, remote_directory:) @@ -60,56 +58,48 @@ module StubObjectStorage def stub_artifacts_object_storage(uploader = JobArtifactUploader, **params) stub_object_storage_uploader(config: Gitlab.config.artifacts.object_store, uploader: uploader, - remote_directory: 'artifacts', **params) end def stub_external_diffs_object_storage(uploader = described_class, **params) stub_object_storage_uploader(config: Gitlab.config.external_diffs.object_store, uploader: uploader, - remote_directory: 'external-diffs', **params) end def stub_lfs_object_storage(**params) stub_object_storage_uploader(config: Gitlab.config.lfs.object_store, uploader: LfsObjectUploader, - remote_directory: 'lfs-objects', **params) end def stub_package_file_object_storage(**params) stub_object_storage_uploader(config: Gitlab.config.packages.object_store, uploader: ::Packages::PackageFileUploader, - remote_directory: 'packages', **params) end def stub_composer_cache_object_storage(**params) stub_object_storage_uploader(config: Gitlab.config.packages.object_store, uploader: ::Packages::Composer::CacheUploader, - remote_directory: 'packages', **params) end def stub_uploads_object_storage(uploader = described_class, **params) stub_object_storage_uploader(config: Gitlab.config.uploads.object_store, uploader: uploader, - remote_directory: 'uploads', **params) end def stub_terraform_state_object_storage(**params) stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store, uploader: Terraform::StateUploader, - remote_directory: 'terraform', **params) end def stub_pages_object_storage(uploader = described_class, **params) stub_object_storage_uploader(config: Gitlab.config.pages.object_store, uploader: uploader, - remote_directory: 'pages', **params) end diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index badd4e8212c..acbc15f7b62 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -9,7 +9,7 @@ module TestEnv # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { - 'signed-commits' => '6101e87', + 'signed-commits' => 'c7794c1', 'not-merged-branch' => 'b83d6e3', 'branch-merged' => '498214d', 'empty-branch' => '7efb185', @@ -53,7 +53,7 @@ module TestEnv 'wip' => 'b9238ee', 'csv' => '3dd0896', 'v1.1.0' => 'b83d6e3', - 'add-ipython-files' => 'f6b7a70', + 'add-ipython-files' => '2b5ef814', 'add-pdf-file' => 'e774ebd', 'squash-large-files' => '54cec52', 'add-pdf-text-binary' => '79faa7b', diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index 5ead1813439..5865bafd382 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -162,6 +162,8 @@ module UsageDataHelpers def stub_usage_data_connections allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + allow(::Ci::ApplicationRecord.connection).to receive(:transaction_open?).and_return(false) if ::Ci::ApplicationRecord.connection_class? + allow(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(false) end diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb index cd8387de686..83bda6e03b1 100644 --- a/spec/support/helpers/workhorse_helpers.rb +++ b/spec/support/helpers/workhorse_helpers.rb @@ -24,7 +24,12 @@ module WorkhorseHelpers # workhorse_post_with_file will transform file_key inside params as if it was disk accelerated by workhorse def workhorse_post_with_file(url, file_key:, params:) - workhorse_request_with_file(:post, url, + workhorse_form_with_file(url, method: :post, file_key: file_key, params: params) + end + + # workhorse_form_with_file will transform file_key inside params as if it was disk accelerated by workhorse + def workhorse_form_with_file(url, file_key:, params:, method: :post) + workhorse_request_with_file(method, url, file_key: file_key, params: params, env: { 'CONTENT_TYPE' => 'multipart/form-data' }, diff --git a/spec/support/matchers/access_matchers.rb b/spec/support/matchers/access_matchers.rb index acf5fb0944f..1b460fbdbf7 100644 --- a/spec/support/matchers/access_matchers.rb +++ b/spec/support/matchers/access_matchers.rb @@ -52,7 +52,7 @@ module AccessMatchers emulate_user(user, @membership) visit(url) - status_code == 200 && !current_path.in?([new_user_session_path, new_admin_session_path]) + [200, 204].include?(status_code) && !current_path.in?([new_user_session_path, new_admin_session_path]) end chain :of do |membership| diff --git a/spec/support/matchers/project_namespace_matcher.rb b/spec/support/matchers/project_namespace_matcher.rb new file mode 100644 index 00000000000..95aa5429679 --- /dev/null +++ b/spec/support/matchers/project_namespace_matcher.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :be_in_sync_with_project do |project| + match do |project_namespace| + # if project is not persisted make sure we do not have a persisted project_namespace for it + break false if project.new_record? && project_namespace&.persisted? + # don't really care if project is not in sync if the project was never persisted. + break true if project.new_record? && !project_namespace.present? + + project_namespace.present? && + project.name == project_namespace.name && + project.path == project_namespace.path && + project.namespace == project_namespace.parent && + project.visibility_level == project_namespace.visibility_level && + project.shared_runners_enabled == project_namespace.shared_runners_enabled + end + + failure_message_when_negated do |project_namespace| + if project.new_record? && project_namespace&.persisted? + "expected that a non persisted project #{project} does not have a persisted project namespace #{project_namespace}" + else + <<-MSG + expected that the project's attributes name, path, namespace_id, visibility_level, shared_runners_enabled + are in sync with the corresponding project namespace attributes + MSG + end + end +end diff --git a/spec/support/patches/rspec_example_prepended_methods.rb b/spec/support/patches/rspec_example_prepended_methods.rb new file mode 100644 index 00000000000..ea918b1e08f --- /dev/null +++ b/spec/support/patches/rspec_example_prepended_methods.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RSpec + module Core + module ExamplePrependedMethods + # Based on https://github.com/rspec/rspec-core/blob/d57c371ee92b16211b80ac7b0b025968438f5297/lib/rspec/core/example.rb#L96-L104, + # Same as location_rerun_argument but with line number + def file_path_rerun_argument + loaded_spec_files = RSpec.configuration.loaded_spec_files + + RSpec::Core::Metadata.ascending(metadata) do |meta| + break meta[:file_path] if loaded_spec_files.include?(meta[:absolute_file_path]) + end + end + end + + module ExampleProcsyPrependedMethods + def file_path_rerun_argument + example.file_path_rerun_argument + end + end + end +end + +RSpec::Core::Example.prepend(RSpec::Core::ExamplePrependedMethods) +RSpec::Core::Example::Procsy.prepend(RSpec::Core::ExampleProcsyPrependedMethods) diff --git a/spec/support/redis/redis_shared_examples.rb b/spec/support/redis/redis_shared_examples.rb index dd916aea3e8..72b3a72f9d4 100644 --- a/spec/support/redis/redis_shared_examples.rb +++ b/spec/support/redis/redis_shared_examples.rb @@ -87,6 +87,43 @@ RSpec.shared_examples "redis_shared_examples" do end end + describe '.store' do + let(:rails_env) { 'development' } + + subject { described_class.new(rails_env).store } + + shared_examples 'redis store' do + it 'instantiates Redis::Store' do + is_expected.to be_a(::Redis::Store) + expect(subject.to_s).to eq("Redis Client connected to #{host} against DB #{redis_database}") + end + + context 'with the namespace' do + let(:namespace) { 'namespace_name' } + + subject { described_class.new(rails_env).store(namespace: namespace) } + + it "uses specified namespace" do + expect(subject.to_s).to eq("Redis Client connected to #{host} against DB #{redis_database} with namespace #{namespace}") + end + end + end + + context 'with old format' do + it_behaves_like 'redis store' do + let(:config_file_name) { config_old_format_host } + let(:host) { "localhost:#{redis_port}" } + end + end + + context 'with new format' do + it_behaves_like 'redis store' do + let(:config_file_name) { config_new_format_host } + let(:host) { "development-host:#{redis_port}" } + end + end + end + describe '.params' do subject { described_class.new(rails_env).params } diff --git a/spec/support/retriable.rb b/spec/support/retriable.rb new file mode 100644 index 00000000000..be4c2d62752 --- /dev/null +++ b/spec/support/retriable.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Retriable.configure do |config| + config.multiplier = 1.0 + config.rand_factor = 0.0 + config.base_interval = 0 +end diff --git a/spec/support/shared_contexts/graphql/requests/packages_shared_context.rb b/spec/support/shared_contexts/graphql/requests/packages_shared_context.rb index 645ea742f07..9ac3d4a04f9 100644 --- a/spec/support/shared_contexts/graphql/requests/packages_shared_context.rb +++ b/spec/support/shared_contexts/graphql/requests/packages_shared_context.rb @@ -10,6 +10,7 @@ RSpec.shared_context 'package details setup' do let(:excluded) { %w[metadata apiFuzzingCiConfiguration pipeline packageFiles] } let(:package_files) { all_graphql_fields_for('PackageFile') } let(:dependency_links) { all_graphql_fields_for('PackageDependencyLink') } + let(:pipelines) { all_graphql_fields_for('Pipeline', max_depth: 1) } let(:user) { project.owner } let(:package_details) { graphql_data_at(:package) } let(:metadata_response) { graphql_data_at(:package, :metadata) } @@ -34,6 +35,11 @@ RSpec.shared_context 'package details setup' do #{dependency_links} } } + pipelines { + nodes { + #{pipelines} + } + } FIELDS end end diff --git a/spec/support/shared_contexts/lib/gitlab/database/background_migration_job_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/database/background_migration_job_shared_context.rb deleted file mode 100644 index 382eb796f8e..00000000000 --- a/spec/support/shared_contexts/lib/gitlab/database/background_migration_job_shared_context.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_context 'background migration job class' do - let!(:job_class_name) { 'TestJob' } - let!(:job_class) { Class.new } - let!(:job_perform_method) do - ->(*arguments) do - Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded( - # Value is 'TestJob' defined by :job_class_name in the let! above. - # Scoping prohibits us from directly referencing job_class_name. - RSpec.current_example.example_group_instance.job_class_name, - arguments - ) - end - end - - before do - job_class.define_method(:perform, job_perform_method) - expect(Gitlab::BackgroundMigration).to receive(:migration_class_for).with(job_class_name).at_least(:once) { job_class } - end -end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 2abc52fce85..bcc6abdc308 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -119,7 +119,7 @@ RSpec.shared_context 'project navbar structure' do _('Repository'), _('CI/CD'), _('Monitor'), - (s_('UsageQuota|Usage Quotas') if Feature.enabled?(:project_storage_ui, default_enabled: :yaml)) + s_('UsageQuota|Usage Quotas') ] } ].compact diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index d7e4864cb08..8a90f887381 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -15,7 +15,7 @@ RSpec.shared_context 'ProjectPolicy context' do let(:base_guest_permissions) do %i[ - award_emoji create_issue create_incident create_merge_request_in create_note + award_emoji create_issue create_merge_request_in create_note create_project read_issue_board read_issue read_issue_iid read_issue_link read_label read_issue_board_list read_milestone read_note read_project read_project_for_iids read_project_member read_release read_snippet @@ -25,10 +25,11 @@ RSpec.shared_context 'ProjectPolicy context' do let(:base_reporter_permissions) do %i[ - admin_issue admin_issue_link admin_label admin_issue_board_list create_snippet - daily_statistics download_code download_wiki_code fork_project metrics_dashboard - read_build read_commit_status read_confidential_issues - read_container_image read_deployment read_environment read_merge_request + admin_issue admin_issue_link admin_label admin_issue_board_list + create_snippet create_incident daily_statistics download_code + download_wiki_code fork_project metrics_dashboard read_build + read_commit_status read_confidential_issues read_container_image + read_deployment read_environment read_merge_request read_metrics_dashboard_annotation read_pipeline read_prometheus read_sentry_issue update_issue ] diff --git a/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb b/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb new file mode 100644 index 00000000000..95b8b7ed9f8 --- /dev/null +++ b/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +RSpec.shared_context 'Debian repository shared context' do |container_type, can_freeze| + include_context 'workhorse headers' + + before do + stub_feature_flags(debian_packages: true, debian_group_packages: true) + end + + let_it_be(:private_container, freeze: can_freeze) { create(container_type, :private) } + let_it_be(:public_container, freeze: can_freeze) { create(container_type, :public) } + let_it_be(:user, freeze: true) { create(:user) } + let_it_be(:personal_access_token, freeze: true) { create(:personal_access_token, user: user) } + + let_it_be(:private_distribution, freeze: true) { create("debian_#{container_type}_distribution", :with_file, container: private_container, codename: 'existing-codename') } + let_it_be(:private_distribution_key, freeze: true) { create("debian_#{container_type}_distribution_key", distribution: private_distribution) } + let_it_be(:private_component, freeze: true) { create("debian_#{container_type}_component", distribution: private_distribution, name: 'existing-component') } + let_it_be(:private_architecture_all, freeze: true) { create("debian_#{container_type}_architecture", distribution: private_distribution, name: 'all') } + let_it_be(:private_architecture, freeze: true) { create("debian_#{container_type}_architecture", distribution: private_distribution, name: 'existing-arch') } + let_it_be(:private_component_file) { create("debian_#{container_type}_component_file", component: private_component, architecture: private_architecture) } + + let_it_be(:public_distribution, freeze: true) { create("debian_#{container_type}_distribution", :with_file, container: public_container, codename: 'existing-codename') } + let_it_be(:public_distribution_key, freeze: true) { create("debian_#{container_type}_distribution_key", distribution: public_distribution) } + let_it_be(:public_component, freeze: true) { create("debian_#{container_type}_component", distribution: public_distribution, name: 'existing-component') } + let_it_be(:public_architecture_all, freeze: true) { create("debian_#{container_type}_architecture", distribution: public_distribution, name: 'all') } + let_it_be(:public_architecture, freeze: true) { create("debian_#{container_type}_architecture", distribution: public_distribution, name: 'existing-arch') } + let_it_be(:public_component_file) { create("debian_#{container_type}_component_file", component: public_component, architecture: public_architecture) } + + if container_type == :group + let_it_be(:private_project) { create(:project, :private, group: private_container) } + let_it_be(:public_project) { create(:project, :public, group: public_container) } + let_it_be(:private_project_distribution) { create(:debian_project_distribution, container: private_project, codename: 'existing-codename') } + let_it_be(:public_project_distribution) { create(:debian_project_distribution, container: public_project, codename: 'existing-codename') } + + let(:project) { { private: private_project, public: public_project }[visibility_level] } + else + let_it_be(:private_project) { private_container } + let_it_be(:public_project) { public_container } + let_it_be(:private_project_distribution) { private_distribution } + let_it_be(:public_project_distribution) { public_distribution } + end + + let_it_be(:private_package) { create(:debian_package, project: private_project, published_in: private_project_distribution) } + let_it_be(:public_package) { create(:debian_package, project: public_project, published_in: public_project_distribution) } + + let(:visibility_level) { :public } + + let(:distribution) { { private: private_distribution, public: public_distribution }[visibility_level] } + let(:architecture) { { private: private_architecture, public: public_architecture }[visibility_level] } + let(:component) { { private: private_component, public: public_component }[visibility_level] } + let(:component_file) { { private: private_component_file, public: public_component_file }[visibility_level] } + let(:package) { { private: private_package, public: public_package }[visibility_level] } + let(:letter) { package.name[0..2] == 'lib' ? package.name[0..3] : package.name[0] } + + let(:method) { :get } + + let(:workhorse_params) do + if method == :put + file_upload = fixture_file_upload("spec/fixtures/packages/debian/#{file_name}") + { file: file_upload } + else + {} + end + end + + let(:api_params) { workhorse_params } + + let(:auth_headers) { {} } + let(:wh_headers) do + if method == :put + workhorse_headers + else + {} + end + end + + let(:headers) { auth_headers.merge(wh_headers) } + + let(:send_rewritten_field) { true } + + subject do + if method == :put + workhorse_finalize( + api(url), + method: method, + file_key: :file, + params: api_params, + headers: headers, + send_rewritten_field: send_rewritten_field + ) + else + send method, api(url), headers: headers, params: api_params + end + end +end + +RSpec.shared_context 'Debian repository auth headers' do |user_type, auth_method = :private_token| + let(:token) { user_type == :invalid_token ? 'wrong' : personal_access_token.token } + + let(:auth_headers) do + if user_type == :anonymous + {} + elsif auth_method == :private_token + { 'Private-Token' => token } + else + basic_auth_header(user.username, token) + end + end +end + +RSpec.shared_context 'Debian repository access' do |visibility_level, user_type, auth_method| + include_context 'Debian repository auth headers', user_type, auth_method do + let(:containers) { { private: private_container, public: public_container } } + let(:container) { containers[visibility_level] } + + before do + container.send("add_#{user_type}", user) if user_type != :anonymous && user_type != :not_a_member && user_type != :invalid_token + end + end +end diff --git a/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb b/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb index 80f011f622b..21be989d697 100644 --- a/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb +++ b/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb @@ -31,14 +31,14 @@ RSpec.shared_context 'container repository delete tags service shared context' d end end - def stub_put_manifest_request(tag, status = 200, headers = { 'docker-content-digest' => 'sha256:dummy' }) + def stub_put_manifest_request(tag, status = 200, headers = { DependencyProxy::Manifest::DIGEST_HEADER => 'sha256:dummy' }) stub_request(:put, "http://registry.gitlab/v2/#{repository.path}/manifests/#{tag}") .to_return(status: status, body: '', headers: headers) end def stub_tag_digest(tag, digest) stub_request(:head, "http://registry.gitlab/v2/#{repository.path}/manifests/#{tag}") - .to_return(status: 200, body: '', headers: { 'docker-content-digest' => digest }) + .to_return(status: 200, body: '', headers: { DependencyProxy::Manifest::DIGEST_HEADER => digest }) end def stub_digest_config(digest, created_at) diff --git a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb index 2b810e790f0..e1d864213b5 100644 --- a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb +++ b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb @@ -38,6 +38,11 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do ) end + after do |example| + Gitlab::Usage::Metric.instance_variable_set(:@all, nil) + Gitlab::Usage::MetricDefinition.instance_variable_set(:@all, nil) + end + def metric_attributes(key_path, category, value_type = 'string') { 'key_path' => key_path, diff --git a/spec/support/shared_contexts/url_shared_context.rb b/spec/support/shared_contexts/url_shared_context.rb index f3d227b6e2b..da1d6e0049c 100644 --- a/spec/support/shared_contexts/url_shared_context.rb +++ b/spec/support/shared_contexts/url_shared_context.rb @@ -1,19 +1,32 @@ # frozen_string_literal: true +RSpec.shared_context 'valid urls with CRLF' do + let(:valid_urls_with_CRLF) do + [ + "http://example.com/pa%0dth", + "http://example.com/pa%0ath", + "http://example.com/pa%0d%0th", + "http://example.com/pa%0D%0Ath", + "http://gitlab.com/path?param=foo%0Abar", + "https://gitlab.com/path?param=foo%0Dbar", + "http://example.org:1024/path?param=foo%0D%0Abar", + "https://storage.googleapis.com/bucket/import_export_upload/import_file/57265/express.tar.gz?GoogleAccessId=hello@example.org&Signature=ABCD%0AEFGHik&Expires=1634663304" + ] + end +end + RSpec.shared_context 'invalid urls' do let(:urls_with_CRLF) do - ["http://127.0.0.1:333/pa\rth", - "http://127.0.0.1:333/pa\nth", - "http://127.0a.0.1:333/pa\r\nth", - "http://127.0.0.1:333/path?param=foo\r\nbar", - "http://127.0.0.1:333/path?param=foo\rbar", - "http://127.0.0.1:333/path?param=foo\nbar", - "http://127.0.0.1:333/pa%0dth", - "http://127.0.0.1:333/pa%0ath", - "http://127.0a.0.1:333/pa%0d%0th", - "http://127.0.0.1:333/pa%0D%0Ath", - "http://127.0.0.1:333/path?param=foo%0Abar", - "http://127.0.0.1:333/path?param=foo%0Dbar", - "http://127.0.0.1:333/path?param=foo%0D%0Abar"] + [ + "git://example.com/pa%0dth", + "git://example.com/pa%0ath", + "git://example.com/pa%0d%0th", + "http://example.com/pa\rth", + "http://example.com/pa\nth", + "http://example.com/pa\r\nth", + "http://example.com/path?param=foo\r\nbar", + "http://example.com/path?param=foo\rbar", + "http://example.com/path?param=foo\nbar" + ] end end diff --git a/spec/support/shared_examples/bulk_imports/common/pipelines/wiki_pipeline_examples.rb b/spec/support/shared_examples/bulk_imports/common/pipelines/wiki_pipeline_examples.rb new file mode 100644 index 00000000000..e8cc666605b --- /dev/null +++ b/spec/support/shared_examples/bulk_imports/common/pipelines/wiki_pipeline_examples.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'wiki pipeline imports a wiki for an entity' do + describe '#run' do + let_it_be(:bulk_import_configuration) { create(:bulk_import_configuration, bulk_import: bulk_import) } + + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + + let(:extracted_data) { BulkImports::Pipeline::ExtractedData.new(data: {}) } + + context 'successfully imports wiki for an entity' do + subject { described_class.new(context) } + + before do + allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(extracted_data) + end + end + + it 'imports new wiki into destination project' do + expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| + url = "https://oauth2:token@gitlab.example/#{entity.source_full_path}.wiki.git" + expect(repository_service).to receive(:fetch_remote).with(url, any_args).and_return 0 + end + + subject.run + end + end + end +end diff --git a/spec/support/shared_examples/controllers/concerns/integrations/integrations_actions_shared_examples.rb b/spec/support/shared_examples/controllers/concerns/integrations/integrations_actions_shared_examples.rb new file mode 100644 index 00000000000..a8aed0c1f0b --- /dev/null +++ b/spec/support/shared_examples/controllers/concerns/integrations/integrations_actions_shared_examples.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.shared_examples Integrations::Actions do + let(:integration) do + create(:datadog_integration, + integration_attributes.merge( + api_url: 'http://example.com', + api_key: 'secret' + ) + ) + end + + describe 'GET #edit' do + before do + get :edit, params: routing_params + end + + it 'assigns the integration' do + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:integration)).to eq(integration) + end + end + + describe 'PUT #update' do + let(:params) do + { + datadog_env: 'env', + datadog_service: 'service' + } + end + + before do + put :update, params: routing_params.merge(integration: params) + end + + it 'updates the integration with the provided params and redirects to the form' do + expect(response).to redirect_to(routing_params.merge(action: :edit)) + expect(integration.reload).to have_attributes(params) + end + + context 'when sending a password field' do + let(:params) { super().merge(api_key: 'new') } + + it 'updates the integration with the password and other params' do + expect(response).to be_redirect + expect(integration.reload).to have_attributes(params) + end + end + + context 'when sending a blank password field' do + let(:params) { super().merge(api_key: '') } + + it 'ignores the password field and saves the other params' do + expect(response).to be_redirect + expect(integration.reload).to have_attributes(params.merge(api_key: 'secret')) + end + end + end +end diff --git a/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb b/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb deleted file mode 100644 index 748a3acf17b..00000000000 --- a/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples IntegrationsActions do - let(:integration) do - create(:datadog_integration, - integration_attributes.merge( - api_url: 'http://example.com', - api_key: 'secret' - ) - ) - end - - describe 'GET #edit' do - before do - get :edit, params: routing_params - end - - it 'assigns the integration' do - expect(response).to have_gitlab_http_status(:ok) - expect(assigns(:integration)).to eq(integration) - end - end - - describe 'PUT #update' do - let(:params) do - { - datadog_env: 'env', - datadog_service: 'service' - } - end - - before do - put :update, params: routing_params.merge(integration: params) - end - - it 'updates the integration with the provided params and redirects to the form' do - expect(response).to redirect_to(routing_params.merge(action: :edit)) - expect(integration.reload).to have_attributes(params) - end - - context 'when sending a password field' do - let(:params) { super().merge(api_key: 'new') } - - it 'updates the integration with the password and other params' do - expect(response).to be_redirect - expect(integration.reload).to have_attributes(params) - end - end - - context 'when sending a blank password field' do - let(:params) { super().merge(api_key: '') } - - it 'ignores the password field and saves the other params' do - expect(response).to be_redirect - expect(integration.reload).to have_attributes(params.merge(api_key: 'secret')) - end - end - end -end diff --git a/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb b/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb index 74a98c20383..8affe4ac8f5 100644 --- a/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb +++ b/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb @@ -6,39 +6,41 @@ # - request_full_path RSpec.shared_examples 'request exceeding rate limit' do - before do - stub_application_setting(notes_create_limit: 2) - 2.times { post :create, params: params } - end + context 'with rate limiter', :freeze_time, :clean_gitlab_redis_rate_limiting do + before do + stub_application_setting(notes_create_limit: 2) + 2.times { post :create, params: params } + end - it 'prevents from creating more notes', :request_store do - expect { post :create, params: params } - .to change { Note.count }.by(0) + it 'prevents from creating more notes' do + expect { post :create, params: params } + .to change { Note.count }.by(0) - expect(response).to have_gitlab_http_status(:too_many_requests) - expect(response.body).to eq(_('This endpoint has been requested too many times. Try again later.')) - end + expect(response).to have_gitlab_http_status(:too_many_requests) + expect(response.body).to eq(_('This endpoint has been requested too many times. Try again later.')) + end - it 'logs the event in auth.log' do - attributes = { - message: 'Application_Rate_Limiter_Request', - env: :notes_create_request_limit, - remote_ip: '0.0.0.0', - request_method: 'POST', - path: request_full_path, - user_id: user.id, - username: user.username - } + it 'logs the event in auth.log' do + attributes = { + message: 'Application_Rate_Limiter_Request', + env: :notes_create_request_limit, + remote_ip: '0.0.0.0', + request_method: 'POST', + path: request_full_path, + user_id: user.id, + username: user.username + } - expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once - post :create, params: params - end + expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once + post :create, params: params + end - it 'allows user in allow-list to create notes, even if the case is different' do - user.update_attribute(:username, user.username.titleize) - stub_application_setting(notes_create_limit_allowlist: ["#{user.username.downcase}"]) + it 'allows user in allow-list to create notes, even if the case is different' do + user.update_attribute(:username, user.username.titleize) + stub_application_setting(notes_create_limit_allowlist: ["#{user.username.downcase}"]) - post :create, params: params - expect(response).to have_gitlab_http_status(:found) + post :create, params: params + expect(response).to have_gitlab_http_status(:found) + end end end diff --git a/spec/support/shared_examples/features/2fa_shared_examples.rb b/spec/support/shared_examples/features/2fa_shared_examples.rb index ddc03e178ba..94c91556ea7 100644 --- a/spec/support/shared_examples/features/2fa_shared_examples.rb +++ b/spec/support/shared_examples/features/2fa_shared_examples.rb @@ -18,6 +18,7 @@ RSpec.shared_examples 'hardware device for 2fa' do |device_type| let(:user) { create(:user) } before do + stub_feature_flags(bootstrap_confirmation_modals: false) gitlab_sign_in(user) user.update_attribute(:otp_required_for_login, true) end diff --git a/spec/support/shared_examples/features/dependency_proxy_shared_examples.rb b/spec/support/shared_examples/features/dependency_proxy_shared_examples.rb index d29c677a962..5d1488502d2 100644 --- a/spec/support/shared_examples/features/dependency_proxy_shared_examples.rb +++ b/spec/support/shared_examples/features/dependency_proxy_shared_examples.rb @@ -26,7 +26,7 @@ RSpec.shared_examples 'a successful manifest pull' do subject expect(response).to have_gitlab_http_status(:ok) - expect(response.headers['Docker-Content-Digest']).to eq(manifest.digest) + expect(response.headers[DependencyProxy::Manifest::DIGEST_HEADER]).to eq(manifest.digest) expect(response.headers['Content-Length']).to eq(manifest.size) expect(response.headers['Docker-Distribution-Api-Version']).to eq(DependencyProxy::DISTRIBUTION_API_VERSION) expect(response.headers['Etag']).to eq("\"#{manifest.digest}\"") diff --git a/spec/support/shared_examples/features/manage_applications_shared_examples.rb b/spec/support/shared_examples/features/manage_applications_shared_examples.rb index 0161899cb76..27d50c67f24 100644 --- a/spec/support/shared_examples/features/manage_applications_shared_examples.rb +++ b/spec/support/shared_examples/features/manage_applications_shared_examples.rb @@ -18,6 +18,7 @@ RSpec.shared_examples 'manage applications' do click_on 'Save application' validate_application(application_name, 'Yes') + expect(page).to have_link('Continue', href: index_path) application = Doorkeeper::Application.find_by(name: application_name) expect(page).to have_css("button[title=\"Copy secret\"][data-clipboard-text=\"#{application.secret}\"]", text: 'Copy') @@ -33,6 +34,7 @@ RSpec.shared_examples 'manage applications' do click_on 'Save application' validate_application(application_name_changed, 'No') + expect(page).not_to have_link('Continue') visit_applications_path diff --git a/spec/support/shared_examples/features/packages_shared_examples.rb b/spec/support/shared_examples/features/packages_shared_examples.rb index 96be30b9f1f..d14b4638ca5 100644 --- a/spec/support/shared_examples/features/packages_shared_examples.rb +++ b/spec/support/shared_examples/features/packages_shared_examples.rb @@ -21,10 +21,6 @@ end RSpec.shared_examples 'package details link' do |property| let(:package) { packages.first } - before do - stub_feature_flags(packages_details_one_column: false) - end - it 'navigates to the correct url' do page.within(packages_table_selector) do click_link package.name @@ -32,7 +28,7 @@ RSpec.shared_examples 'package details link' do |property| expect(page).to have_current_path(project_package_path(package.project, package)) - expect(page).to have_css('.packages-app h1[data-testid="title"]', text: package.name) + expect(page).to have_css('.packages-app h2[data-testid="title"]', text: package.name) expect(page).to have_content('Installation') expect(page).to have_content('Registry setup') @@ -94,16 +90,24 @@ def packages_table_selector end def click_sort_option(option, ascending) - page.within('.gl-sorting') do - # Reset the sort direction - click_button 'Sort direction' if page.has_selector?('svg[aria-label="Sorting Direction: Ascending"]', wait: 0) + wait_for_requests - find('button.gl-dropdown-toggle').click + # Reset the sort direction + if page.has_selector?('button[aria-label="Sorting Direction: Ascending"]', wait: 0) && !ascending + click_button 'Sort direction' - page.within('.dropdown-menu') do - click_button option - end + wait_for_requests + end + + find('button.gl-dropdown-toggle').click + + page.within('.dropdown-menu') do + click_button option + end + + if ascending + wait_for_requests - click_button 'Sort direction' if ascending + click_button 'Sort direction' end end diff --git a/spec/support/shared_examples/features/resolving_discussions_in_issues_shared_examples.rb b/spec/support/shared_examples/features/resolving_discussions_in_issues_shared_examples.rb index 6d44a6fde85..337b3f3cbd0 100644 --- a/spec/support/shared_examples/features/resolving_discussions_in_issues_shared_examples.rb +++ b/spec/support/shared_examples/features/resolving_discussions_in_issues_shared_examples.rb @@ -1,43 +1,29 @@ # frozen_string_literal: true RSpec.shared_examples 'creating an issue for a thread' do - it 'shows an issue with the title filled in' do + it 'shows an issue creation form' do + # Title field is filled in title_field = page.find_field('issue[title]') - expect(title_field.value).to include(merge_request.title) - end - it 'has a mention of the discussion in the description' do - description_field = page.find_field('issue[description]') + # Has a hidden field for the merge request + merge_request_field = find('#merge_request_to_resolve_discussions_of', visible: false) + expect(merge_request_field.value).to eq(merge_request.iid.to_s) + # Has a mention of the discussion in the description + description_field = page.find_field('issue[description]') expect(description_field.value).to include(discussion.first_note.note) end - it 'can create a new issue for the project' do + it 'creates a new issue for the project' do + # Actually creates an issue for the project expect { click_button 'Create issue' }.to change { project.issues.reload.size }.by(1) - end - - it 'resolves the discussion in the merge request' do - click_button 'Create issue' + # Resolves the discussion in the merge request discussion.first_note.reload - expect(discussion.resolved?).to eq(true) - end - - it 'shows a flash messaage after resolving a discussion' do - click_button 'Create issue' - - page.within '.flash-notice' do - # Only check for the word 'Resolved' since the spec might have resolved - # multiple discussions - expect(page).to have_content('Resolved') - end - end - - it 'has a hidden field for the merge request' do - merge_request_field = find('#merge_request_to_resolve_discussions_of', visible: false) - expect(merge_request_field.value).to eq(merge_request.iid.to_s) + # Issue title inludes MR title + expect(page).to have_content(%Q(Follow-up from "#{merge_request.title}")) end end diff --git a/spec/support/shared_examples/features/sidebar_shared_examples.rb b/spec/support/shared_examples/features/sidebar_shared_examples.rb index 5bfe929e957..d509d124de0 100644 --- a/spec/support/shared_examples/features/sidebar_shared_examples.rb +++ b/spec/support/shared_examples/features/sidebar_shared_examples.rb @@ -52,16 +52,17 @@ RSpec.shared_examples 'issue boards sidebar' do it 'shows toggle as on then as off as user toggles to subscribe and unsubscribe', :aggregate_failures do wait_for_requests + subscription_button = find('[data-testid="subscription-toggle"]') - click_button 'Notifications' + subscription_button.click - expect(page).to have_button('Notifications', class: 'is-checked') + expect(subscription_button).to have_css("button.is-checked") - click_button 'Notifications' + subscription_button.click wait_for_requests - expect(page).not_to have_button('Notifications', class: 'is-checked') + expect(subscription_button).to have_css("button:not(.is-checked)") end context 'when notifications have been disabled' do @@ -73,7 +74,7 @@ RSpec.shared_examples 'issue boards sidebar' do it 'displays a message that notifications have been disabled' do page.within('[data-testid="sidebar-notifications"]') do - expect(page).to have_button('Notifications', class: 'is-disabled') + expect(page).to have_selector('[data-testid="subscription-toggle"]', class: 'is-disabled') expect(page).to have_content('Disabled by project owner') end end diff --git a/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb b/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb index fb598b978f6..56b6dc682eb 100644 --- a/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb +++ b/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb @@ -66,20 +66,22 @@ RSpec.shared_examples 'a Note mutation when the given resource id is not for a N end RSpec.shared_examples 'a Note mutation when there are rate limit validation errors' do - before do - stub_application_setting(notes_create_limit: 3) - 3.times { post_graphql_mutation(mutation, current_user: current_user) } - end - - it_behaves_like 'a Note mutation that does not create a Note' - it_behaves_like 'a mutation that returns top-level errors', - errors: ['This endpoint has been requested too many times. Try again later.'] - - context 'when the user is in the allowlist' do + context 'with rate limiter', :freeze_time, :clean_gitlab_redis_rate_limiting do before do - stub_application_setting(notes_create_limit_allowlist: ["#{current_user.username}"]) + stub_application_setting(notes_create_limit: 3) + 3.times { post_graphql_mutation(mutation, current_user: current_user) } end - it_behaves_like 'a Note mutation that creates a Note' + it_behaves_like 'a Note mutation that does not create a Note' + it_behaves_like 'a mutation that returns top-level errors', + errors: ['This endpoint has been requested too many times. Try again later.'] + + context 'when the user is in the allowlist' do + before do + stub_application_setting(notes_create_limit_allowlist: ["#{current_user.username}"]) + end + + it_behaves_like 'a Note mutation that creates a Note' + end end end diff --git a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb index 8b4ecd7d5ae..a3c67210a4a 100644 --- a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb @@ -35,8 +35,8 @@ RSpec.shared_examples 'common trace features' do stub_feature_flags(gitlab_ci_archived_trace_consistent_reads: trace.job.project) end - it 'calls ::ApplicationRecord.sticking.unstick_or_continue_sticking' do - expect(::ApplicationRecord.sticking).to receive(:unstick_or_continue_sticking) + it 'calls ::Ci::Build.sticking.unstick_or_continue_sticking' do + expect(::Ci::Build.sticking).to receive(:unstick_or_continue_sticking) .with(described_class::LOAD_BALANCING_STICKING_NAMESPACE, trace.job.id) .and_call_original @@ -49,8 +49,8 @@ RSpec.shared_examples 'common trace features' do stub_feature_flags(gitlab_ci_archived_trace_consistent_reads: false) end - it 'does not call ::ApplicationRecord.sticking.unstick_or_continue_sticking' do - expect(::ApplicationRecord.sticking).not_to receive(:unstick_or_continue_sticking) + it 'does not call ::Ci::Build.sticking.unstick_or_continue_sticking' do + expect(::Ci::Build.sticking).not_to receive(:unstick_or_continue_sticking) trace.read { |stream| stream } end @@ -305,8 +305,8 @@ RSpec.shared_examples 'common trace features' do stub_feature_flags(gitlab_ci_archived_trace_consistent_reads: trace.job.project) end - it 'calls ::ApplicationRecord.sticking.stick' do - expect(::ApplicationRecord.sticking).to receive(:stick) + it 'calls ::Ci::Build.sticking.stick' do + expect(::Ci::Build.sticking).to receive(:stick) .with(described_class::LOAD_BALANCING_STICKING_NAMESPACE, trace.job.id) .and_call_original @@ -319,8 +319,8 @@ RSpec.shared_examples 'common trace features' do stub_feature_flags(gitlab_ci_archived_trace_consistent_reads: false) end - it 'does not call ::ApplicationRecord.sticking.stick' do - expect(::ApplicationRecord.sticking).not_to receive(:stick) + it 'does not call ::Ci::Build.sticking.stick' do + expect(::Ci::Build.sticking).not_to receive(:stick) subject end @@ -808,7 +808,19 @@ RSpec.shared_examples 'trace with enabled live trace feature' do create(:ci_job_artifact, :trace, job: build) end - it { is_expected.to be_truthy } + it 'is truthy' do + is_expected.to be_truthy + end + end + + context 'when archived trace record exists but file is not stored' do + before do + create(:ci_job_artifact, :unarchived_trace_artifact, job: build) + end + + it 'is falsy' do + is_expected.to be_falsy + end end context 'when live trace exists' do @@ -872,13 +884,35 @@ RSpec.shared_examples 'trace with enabled live trace feature' do build.reload expect(build.trace.exist?).to be_truthy - expect(build.job_artifacts_trace).to be_nil Gitlab::Ci::Trace::ChunkedIO.new(build) do |stream| expect(stream.read).to eq(trace_raw) end end end + shared_examples 'a pre-commit error' do |error:| + it_behaves_like 'source trace in ChunkedIO stays intact', error: error + + it 'does not save the trace artifact' do + expect { subject }.to raise_error(error) + + build.reload + expect(build.job_artifacts_trace).to be_nil + end + end + + shared_examples 'a post-commit error' do |error:| + it_behaves_like 'source trace in ChunkedIO stays intact', error: error + + it 'saves the trace artifact but not the file' do + expect { subject }.to raise_error(error) + + build.reload + expect(build.job_artifacts_trace).to be_present + expect(build.job_artifacts_trace.file.exists?).to be_falsy + end + end + context 'when job does not have trace artifact' do context 'when trace is stored in ChunkedIO' do let!(:build) { create(:ci_build, :success, :trace_live) } @@ -892,7 +926,7 @@ RSpec.shared_examples 'trace with enabled live trace feature' do allow(IO).to receive(:copy_stream).and_return(0) end - it_behaves_like 'source trace in ChunkedIO stays intact', error: Gitlab::Ci::Trace::ArchiveError + it_behaves_like 'a pre-commit error', error: Gitlab::Ci::Trace::ArchiveError end context 'when failed to create job artifact record' do @@ -902,7 +936,16 @@ RSpec.shared_examples 'trace with enabled live trace feature' do .and_return(%w[Error Error]) end - it_behaves_like 'source trace in ChunkedIO stays intact', error: ActiveRecord::RecordInvalid + it_behaves_like 'a pre-commit error', error: ActiveRecord::RecordInvalid + end + + context 'when storing the file raises an error' do + before do + stub_artifacts_object_storage(direct_upload: true) + allow_any_instance_of(Ci::JobArtifact).to receive(:store_file!).and_raise(Excon::Error::BadGateway, 'S3 is down lol') + end + + it_behaves_like 'a post-commit error', error: Excon::Error::BadGateway end end end diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb index 6342064beb8..bea7cca2744 100644 --- a/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb +++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb @@ -6,7 +6,7 @@ shared_examples 'deployment metrics examples' do environment = project.environments.production.first || create(:environment, :production, project: project) create(:deployment, :success, args.merge(environment: environment)) - # this is needed for the dora_deployment_frequency_in_vsa feature flag so we have aggregated data + # this is needed for the DORA API so we have aggregated data ::Dora::DailyMetrics::RefreshWorker.new.perform(environment.id, Time.current.to_date.to_s) if Gitlab.ee? end diff --git a/spec/support/shared_examples/lib/gitlab/database/cte_materialized_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/cte_materialized_shared_examples.rb index a617342ff8c..df795723874 100644 --- a/spec/support/shared_examples/lib/gitlab/database/cte_materialized_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/database/cte_materialized_shared_examples.rb @@ -11,7 +11,7 @@ RSpec.shared_examples 'CTE with MATERIALIZED keyword examples' do context 'when PG version is <12' do it 'does not add MATERIALIZE keyword' do - allow(Gitlab::Database.main).to receive(:version).and_return('11.1') + allow(ApplicationRecord.database).to receive(:version).and_return('11.1') expect(query).to include(expected_query_block_without_materialized) end @@ -19,14 +19,14 @@ RSpec.shared_examples 'CTE with MATERIALIZED keyword examples' do context 'when PG version is >=12' do it 'adds MATERIALIZE keyword' do - allow(Gitlab::Database.main).to receive(:version).and_return('12.1') + allow(ApplicationRecord.database).to receive(:version).and_return('12.1') expect(query).to include(expected_query_block_with_materialized) end context 'when version is higher than 12' do it 'adds MATERIALIZE keyword' do - allow(Gitlab::Database.main).to receive(:version).and_return('15.1') + allow(ApplicationRecord.database).to receive(:version).and_return('15.1') expect(query).to include(expected_query_block_with_materialized) end diff --git a/spec/support/shared_examples/lib/gitlab/import_export/attributes_permitter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/import_export/attributes_permitter_shared_examples.rb index 5ce698c4701..41d3d76b66b 100644 --- a/spec/support/shared_examples/lib/gitlab/import_export/attributes_permitter_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/import_export/attributes_permitter_shared_examples.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -RSpec.shared_examples 'a permitted attribute' do |relation_sym, permitted_attributes| +RSpec.shared_examples 'a permitted attribute' do |relation_sym, permitted_attributes, additional_attributes = []| let(:prohibited_attributes) { %i[remote_url my_attributes my_ids token my_id test] } let(:import_export_config) { Gitlab::ImportExport::Config.new.to_h } @@ -26,7 +26,7 @@ RSpec.shared_examples 'a permitted attribute' do |relation_sym, permitted_attrib end it 'does not contain attributes that would be cleaned with AttributeCleaner' do - expect(cleaned_hash.keys).to include(*permitted_hash.keys) + expect(cleaned_hash.keys + additional_attributes.to_a).to include(*permitted_hash.keys) end it 'does not contain prohibited attributes that are not related to given relation' do diff --git a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb index 708bc71ae96..ff03051ed37 100644 --- a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb @@ -2,7 +2,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| let(:fake_duplicate_job) do - instance_double(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob) + instance_double(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, duplicate_key_ttl: Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DEFAULT_DUPLICATE_KEY_TTL) end let(:expected_message) { "dropped #{strategy_name.to_s.humanize.downcase}" } @@ -11,14 +11,14 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| describe '#schedule' do before do - allow(Gitlab::SidekiqLogging::DeduplicationLogger.instance).to receive(:log) + allow(Gitlab::SidekiqLogging::DeduplicationLogger.instance).to receive(:deduplicated_log) end it 'checks for duplicates before yielding' do expect(fake_duplicate_job).to receive(:scheduled?).twice.ordered.and_return(false) expect(fake_duplicate_job).to( receive(:check!) - .with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL) + .with(fake_duplicate_job.duplicate_key_ttl) .ordered .and_return('a jid')) expect(fake_duplicate_job).to receive(:duplicate?).ordered.and_return(false) @@ -40,6 +40,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to receive(:check!).and_return('the jid') allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) allow(fake_duplicate_job).to receive(:update_latest_wal_location!) + allow(fake_duplicate_job).to receive(:set_deduplicated_flag!) allow(fake_duplicate_job).to receive(:options).and_return({}) job_hash = {} @@ -61,10 +62,11 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to receive(:options).and_return({ including_scheduled: true }) allow(fake_duplicate_job).to( receive(:check!) - .with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL) + .with(fake_duplicate_job.duplicate_key_ttl) .and_return('the jid')) allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) allow(fake_duplicate_job).to receive(:update_latest_wal_location!) + allow(fake_duplicate_job).to receive(:set_deduplicated_flag!) job_hash = {} expect(fake_duplicate_job).to receive(:duplicate?).and_return(true) @@ -83,9 +85,10 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to receive(:scheduled_at).and_return(Time.now + time_diff) allow(fake_duplicate_job).to receive(:options).and_return({ including_scheduled: true }) allow(fake_duplicate_job).to( - receive(:check!).with(time_diff.to_i).and_return('the jid')) + receive(:check!).with(time_diff.to_i + fake_duplicate_job.duplicate_key_ttl).and_return('the jid')) allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) allow(fake_duplicate_job).to receive(:update_latest_wal_location!) + allow(fake_duplicate_job).to receive(:set_deduplicated_flag!) job_hash = {} expect(fake_duplicate_job).to receive(:duplicate?).and_return(true) @@ -100,6 +103,26 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| end end + context "when the job is not duplicate" do + before do + allow(fake_duplicate_job).to receive(:scheduled?).and_return(false) + allow(fake_duplicate_job).to receive(:check!).and_return('the jid') + allow(fake_duplicate_job).to receive(:duplicate?).and_return(false) + allow(fake_duplicate_job).to receive(:options).and_return({}) + allow(fake_duplicate_job).to receive(:existing_jid).and_return('the jid') + end + + it 'does not return false nor drop the job' do + schedule_result = nil + + expect(fake_duplicate_job).not_to receive(:set_deduplicated_flag!) + + expect { |b| schedule_result = strategy.schedule({}, &b) }.to yield_control + + expect(schedule_result).to be_nil + end + end + context "when the job is droppable" do before do allow(fake_duplicate_job).to receive(:scheduled?).and_return(false) @@ -109,6 +132,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to receive(:existing_jid).and_return('the jid') allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) allow(fake_duplicate_job).to receive(:update_latest_wal_location!) + allow(fake_duplicate_job).to receive(:set_deduplicated_flag!) end it 'updates latest wal location' do @@ -117,10 +141,11 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| strategy.schedule({ 'jid' => 'new jid' }) {} end - it 'drops the job' do + it 'returns false to drop the job' do schedule_result = nil expect(fake_duplicate_job).to receive(:idempotent?).and_return(true) + expect(fake_duplicate_job).to receive(:set_deduplicated_flag!).once expect { |b| schedule_result = strategy.schedule({}, &b) }.not_to yield_control expect(schedule_result).to be(false) @@ -130,7 +155,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| fake_logger = instance_double(Gitlab::SidekiqLogging::DeduplicationLogger) expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger) - expect(fake_logger).to receive(:log).with(a_hash_including({ 'jid' => 'new jid' }), expected_message, {}) + expect(fake_logger).to receive(:deduplicated_log).with(a_hash_including({ 'jid' => 'new jid' }), expected_message, {}) strategy.schedule({ 'jid' => 'new jid' }) {} end @@ -140,7 +165,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger) allow(fake_duplicate_job).to receive(:options).and_return({ foo: :bar }) - expect(fake_logger).to receive(:log).with(a_hash_including({ 'jid' => 'new jid' }), expected_message, { foo: :bar }) + expect(fake_logger).to receive(:deduplicated_log).with(a_hash_including({ 'jid' => 'new jid' }), expected_message, { foo: :bar }) strategy.schedule({ 'jid' => 'new jid' }) {} end @@ -159,6 +184,9 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| before do allow(fake_duplicate_job).to receive(:delete!) + allow(fake_duplicate_job).to receive(:scheduled?) { false } + allow(fake_duplicate_job).to receive(:options) { {} } + allow(fake_duplicate_job).to receive(:should_reschedule?) { false } allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( wal_locations ) end diff --git a/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb b/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb new file mode 100644 index 00000000000..d3fd28727b5 --- /dev/null +++ b/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'ZenTao menu with CE version' do + let(:project) { create(:project, has_external_issue_tracker: true) } + let(:user) { project.owner } + let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) } + let(:zentao_integration) { create(:zentao_integration, project: project) } + + subject { described_class.new(context) } + + describe '#render?' do + context 'when issues integration is disabled' do + before do + zentao_integration.update!(active: false) + end + + it 'returns false' do + expect(subject.render?).to eq false + end + end + + context 'when issues integration is enabled' do + before do + zentao_integration.update!(active: true) + end + + it 'returns true' do + expect(subject.render?).to eq true + end + + it 'renders menu link' do + expect(subject.link).to eq zentao_integration.url + end + + it 'contains only open ZenTao item' do + expect(subject.renderable_items.map(&:item_id)).to match_array [:open_zentao] + end + end + end +end diff --git a/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb b/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb new file mode 100644 index 00000000000..7ccd9533811 --- /dev/null +++ b/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'it has loose foreign keys' do + let(:factory_name) { nil } + let(:table_name) { described_class.table_name } + let(:connection) { described_class.connection } + + it 'includes the LooseForeignKey module' do + expect(described_class.ancestors).to include(LooseForeignKey) + end + + it 'responds to #loose_foreign_key_definitions' do + expect(described_class).to respond_to(:loose_foreign_key_definitions) + end + + it 'has at least one loose foreign key definition' do + expect(described_class.loose_foreign_key_definitions.size).to be > 0 + end + + it 'has the deletion trigger present' do + sql = <<-SQL + SELECT trigger_name + FROM information_schema.triggers + WHERE event_object_table = '#{table_name}' + SQL + + triggers = connection.execute(sql) + + expected_trigger_name = "#{table_name}_loose_fk_trigger" + expect(triggers.pluck('trigger_name')).to include(expected_trigger_name) + end + + it 'records record deletions' do + model = create(factory_name) # rubocop: disable Rails/SaveBang + model.destroy! + + deleted_record = LooseForeignKeys::DeletedRecord.find_by(fully_qualified_table_name: "#{connection.current_schema}.#{table_name}", primary_key_value: model.id) + + expect(deleted_record).not_to be_nil + end + + it 'cleans up record deletions' do + model = create(factory_name) # rubocop: disable Rails/SaveBang + + expect { model.destroy! }.to change { LooseForeignKeys::DeletedRecord.count }.by(1) + + LooseForeignKeys::ProcessDeletedRecordsService.new(connection: connection).execute + + expect(LooseForeignKeys::DeletedRecord.status_pending.count).to be(0) + expect(LooseForeignKeys::DeletedRecord.status_processed.count).to be(1) + end +end diff --git a/spec/support/shared_examples/metrics/transaction_metrics_with_labels_shared_examples.rb b/spec/support/shared_examples/metrics/transaction_metrics_with_labels_shared_examples.rb new file mode 100644 index 00000000000..286c60f1f4f --- /dev/null +++ b/spec/support/shared_examples/metrics/transaction_metrics_with_labels_shared_examples.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'transaction metrics with labels' do + let(:sensitive_tags) do + { + path: 'private', + branch: 'sensitive' + } + end + + around do |example| + described_class.reload_metric! + example.run + described_class.reload_metric! + end + + describe '.prometheus_metric' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Histogram, observe: nil, base_labels: {}) } + + it 'adds a metric' do + expect(::Gitlab::Metrics).to receive(:histogram).with( + :meow_observe, 'Meow observe histogram', hash_including(*described_class::BASE_LABEL_KEYS), be_a(Array) + ).and_return(prometheus_metric) + + expect do |block| + metric = described_class.prometheus_metric(:meow_observe, :histogram, &block) + expect(metric).to be(prometheus_metric) + end.to yield_control + end + end + + describe '#method_call_for' do + it 'returns a MethodCall' do + method = transaction_obj.method_call_for('Foo#bar', :Foo, '#bar') + + expect(method).to be_an_instance_of(Gitlab::Metrics::MethodCall) + end + end + + describe '#add_event' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, increment: nil, base_labels: {}) } + + it 'adds a metric' do + expect(prometheus_metric).to receive(:increment).with(labels) + expect(described_class).to receive(:fetch_metric).with(:counter, :gitlab_transaction_event_meow_total).and_return(prometheus_metric) + + transaction_obj.add_event(:meow) + end + + it 'allows tracking of custom tags' do + expect(prometheus_metric).to receive(:increment).with(labels.merge(animal: "dog")) + expect(described_class).to receive(:fetch_metric).with(:counter, :gitlab_transaction_event_bau_total).and_return(prometheus_metric) + + transaction_obj.add_event(:bau, animal: 'dog') + end + + context 'with sensitive tags' do + it 'filters tags' do + expect(described_class).to receive(:fetch_metric).with(:counter, :gitlab_transaction_event_bau_total).and_return(prometheus_metric) + expect(prometheus_metric).not_to receive(:increment).with(hash_including(sensitive_tags)) + + transaction_obj.add_event(:bau, **sensitive_tags.merge(sane: 'yes')) + end + end + end + + describe '#increment' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, increment: nil, base_labels: {}) } + + it 'adds a metric' do + expect(::Gitlab::Metrics).to receive(:counter).with( + :meow, 'Meow counter', hash_including(*described_class::BASE_LABEL_KEYS) + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:increment).with(labels, 1) + + transaction_obj.increment(:meow, 1) + end + + context 'with block' do + it 'overrides docstring' do + expect(::Gitlab::Metrics).to receive(:counter).with( + :block_docstring, 'test', hash_including(*described_class::BASE_LABEL_KEYS) + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:increment).with(labels, 1) + + transaction_obj.increment(:block_docstring, 1) do + docstring 'test' + end + end + + it 'overrides labels' do + expect(::Gitlab::Metrics).to receive(:counter).with( + :block_labels, 'Block labels counter', hash_including(*described_class::BASE_LABEL_KEYS) + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:increment).with(labels.merge(sane: 'yes'), 1) + + transaction_obj.increment(:block_labels, 1, sane: 'yes') do + label_keys %i(sane) + end + end + + it 'filters sensitive tags' do + labels_keys = sensitive_tags.keys + + expect(::Gitlab::Metrics).to receive(:counter).with( + :metric_with_sensitive_block, 'Metric with sensitive block counter', hash_excluding(labels_keys) + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:increment).with(labels, 1) + + transaction_obj.increment(:metric_with_sensitive_block, 1, sensitive_tags) do + label_keys labels_keys + end + end + end + end + + describe '#set' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Gauge, set: nil, base_labels: {}) } + + it 'adds a metric' do + expect(::Gitlab::Metrics).to receive(:gauge).with( + :meow_set, 'Meow set gauge', hash_including(*described_class::BASE_LABEL_KEYS), :all + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:set).with(labels, 99) + + transaction_obj.set(:meow_set, 99) + end + + context 'with block' do + it 'overrides docstring' do + expect(::Gitlab::Metrics).to receive(:gauge).with( + :block_docstring_set, 'test', hash_including(*described_class::BASE_LABEL_KEYS), :all + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:set).with(labels, 99) + + transaction_obj.set(:block_docstring_set, 99) do + docstring 'test' + end + end + + it 'overrides labels' do + expect(::Gitlab::Metrics).to receive(:gauge).with( + :block_labels_set, 'Block labels set gauge', hash_including(*described_class::BASE_LABEL_KEYS), :all + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:set).with(labels.merge(sane: 'yes'), 99) + + transaction_obj.set(:block_labels_set, 99, sane: 'yes') do + label_keys %i(sane) + end + end + + it 'filters sensitive tags' do + labels_keys = sensitive_tags.keys + + expect(::Gitlab::Metrics).to receive(:gauge).with( + :metric_set_with_sensitive_block, 'Metric set with sensitive block gauge', hash_excluding(*labels_keys), :all + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:set).with(labels, 99) + + transaction_obj.set(:metric_set_with_sensitive_block, 99, sensitive_tags) do + label_keys label_keys + end + end + end + end + + describe '#observe' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Histogram, observe: nil, base_labels: {}) } + + it 'adds a metric' do + expect(::Gitlab::Metrics).to receive(:histogram).with( + :meow_observe, 'Meow observe histogram', hash_including(*described_class::BASE_LABEL_KEYS), kind_of(Array) + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:observe).with(labels, 2.0) + + transaction_obj.observe(:meow_observe, 2.0) + end + + context 'with block' do + it 'overrides docstring' do + expect(::Gitlab::Metrics).to receive(:histogram).with( + :block_docstring_observe, 'test', hash_including(*described_class::BASE_LABEL_KEYS), kind_of(Array) + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:observe).with(labels, 2.0) + + transaction_obj.observe(:block_docstring_observe, 2.0) do + docstring 'test' + end + end + + it 'overrides labels' do + expect(::Gitlab::Metrics).to receive(:histogram).with( + :block_labels_observe, 'Block labels observe histogram', hash_including(*described_class::BASE_LABEL_KEYS), kind_of(Array) + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:observe).with(labels.merge(sane: 'yes'), 2.0) + + transaction_obj.observe(:block_labels_observe, 2.0, sane: 'yes') do + label_keys %i(sane) + end + end + + it 'filters sensitive tags' do + labels_keys = sensitive_tags.keys + + expect(::Gitlab::Metrics).to receive(:histogram).with( + :metric_observe_with_sensitive_block, + 'Metric observe with sensitive block histogram', + hash_excluding(labels_keys), + kind_of(Array) + ).and_return(prometheus_metric) + expect(prometheus_metric).to receive(:observe).with(labels, 2.0) + + transaction_obj.observe(:metric_observe_with_sensitive_block, 2.0, sensitive_tags) do + label_keys label_keys + end + end + end + end +end diff --git a/spec/support/shared_examples/models/concerns/analytics/cycle_analytics/stage_event_model_examples.rb b/spec/support/shared_examples/models/concerns/analytics/cycle_analytics/stage_event_model_examples.rb index f928fb1eb43..d823e7ac221 100644 --- a/spec/support/shared_examples/models/concerns/analytics/cycle_analytics/stage_event_model_examples.rb +++ b/spec/support/shared_examples/models/concerns/analytics/cycle_analytics/stage_event_model_examples.rb @@ -12,6 +12,7 @@ RSpec.shared_examples 'StageEventModel' do project_id: 4, author_id: 5, milestone_id: 6, + state_id: 1, start_event_timestamp: time, end_event_timestamp: time }, @@ -22,6 +23,7 @@ RSpec.shared_examples 'StageEventModel' do project_id: 11, author_id: 12, milestone_id: 13, + state_id: 1, start_event_timestamp: time, end_event_timestamp: time } @@ -34,8 +36,9 @@ RSpec.shared_examples 'StageEventModel' do described_class.issuable_id_column, :group_id, :project_id, - :milestone_id, :author_id, + :milestone_id, + :state_id, :start_event_timestamp, :end_event_timestamp ] @@ -59,10 +62,120 @@ RSpec.shared_examples 'StageEventModel' do upsert_data output_data = described_class.all.map do |record| - column_order.map { |column| record[column] } + column_order.map do |column| + if column == :state_id + described_class.states[record[column]] + else + record[column] + end + end end.sort expect(input_data.map(&:values).sort).to eq(output_data) end end + + describe 'scopes' do + def attributes(array) + array.map(&:attributes) + end + + RSpec::Matchers.define :match_attributes do |expected| + match do |actual| + actual.map(&:attributes) == expected.map(&:attributes) + end + end + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:user) } + let_it_be(:milestone) { create(:milestone) } + let_it_be(:issuable_with_assignee) { create(issuable_factory, assignees: [user])} + + let_it_be(:record) { create(stage_event_factory, start_event_timestamp: 3.years.ago.to_date, end_event_timestamp: 2.years.ago.to_date) } + let_it_be(:record_with_author) { create(stage_event_factory, author_id: user.id) } + let_it_be(:record_with_project) { create(stage_event_factory, project_id: project.id) } + let_it_be(:record_with_group) { create(stage_event_factory, group_id: project.namespace_id) } + let_it_be(:record_with_assigned_issuable) { create(stage_event_factory, described_class.issuable_id_column => issuable_with_assignee.id) } + let_it_be(:record_with_milestone) { create(stage_event_factory, milestone_id: milestone.id) } + + it 'filters by stage_event_hash_id' do + records = described_class.by_stage_event_hash_id(record.stage_event_hash_id) + + expect(records).to match_attributes([record]) + end + + it 'filters by project_id' do + records = described_class.by_project_id(project.id) + + expect(records).to match_attributes([record_with_project]) + end + + it 'filters by group_id' do + records = described_class.by_group_id(project.namespace_id) + + expect(records).to match_attributes([record_with_group]) + end + + it 'filters by author_id' do + records = described_class.authored(user) + + expect(records).to match_attributes([record_with_author]) + end + + it 'filters by assignee' do + records = described_class.assigned_to(user) + + expect(records).to match_attributes([record_with_assigned_issuable]) + end + + it 'filters by milestone_id' do + records = described_class.with_milestone_id(milestone.id) + + expect(records).to match_attributes([record_with_milestone]) + end + + describe 'start_event_timestamp filtering' do + it 'when range is given' do + records = described_class + .start_event_timestamp_after(4.years.ago) + .start_event_timestamp_before(2.years.ago) + + expect(records).to match_attributes([record]) + end + + it 'when specifying upper bound' do + records = described_class.start_event_timestamp_before(2.years.ago) + + expect(attributes(records)).to include(attributes([record]).first) + end + + it 'when specifying the lower bound' do + records = described_class.start_event_timestamp_after(4.years.ago) + + expect(attributes(records)).to include(attributes([record]).first) + end + end + + describe 'end_event_timestamp filtering' do + it 'when range is given' do + records = described_class + .end_event_timestamp_after(3.years.ago) + .end_event_timestamp_before(1.year.ago) + + expect(records).to match_attributes([record]) + end + + it 'when specifying upper bound' do + records = described_class.end_event_timestamp_before(1.year.ago) + + expect(attributes(records)).to include(attributes([record]).first) + end + + it 'when specifying the lower bound' do + records = described_class.end_event_timestamp_after(3.years.ago) + + expect(attributes(records)).to include(attributes([record]).first) + end + end + end end diff --git a/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb b/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb index a4e0d6c871e..2d08de297a3 100644 --- a/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb @@ -11,18 +11,18 @@ RSpec.shared_examples 'ttl_expirable' do it { is_expected.to validate_presence_of(:status) } end - describe '.updated_before' do + describe '.read_before' do # rubocop:disable Rails/SaveBang let_it_be_with_reload(:item1) { create(class_symbol) } let_it_be(:item2) { create(class_symbol) } # rubocop:enable Rails/SaveBang before do - item1.update_column(:updated_at, 1.month.ago) + item1.update_column(:read_at, 1.month.ago) end it 'returns items with created at older than the supplied number of days' do - expect(described_class.updated_before(10)).to contain_exactly(item1) + expect(described_class.read_before(10)).to contain_exactly(item1) end end @@ -48,4 +48,13 @@ RSpec.shared_examples 'ttl_expirable' do expect(described_class.lock_next_by(:created_at)).to contain_exactly(item3) end end + + describe '#read', :freeze_time do + let_it_be(:old_read_at) { 1.day.ago } + let_it_be(:item1) { create(class_symbol, read_at: old_read_at) } + + it 'updates read_at' do + expect { item1.read! }.to change { item1.reload.read_at } + end + end end diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb index 56c202cb228..a2909c66e22 100644 --- a/spec/support/shared_examples/models/member_shared_examples.rb +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -299,6 +299,22 @@ RSpec.shared_examples_for "member creation" do end end end + + context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do + before do + stub_experiments(invite_members_for_task: true) + end + + it 'creates a member_task with the correct attributes', :aggregate_failures do + task_project = source.is_a?(Group) ? create(:project, group: source) : source + described_class.new(source, user, :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id).execute + + member = source.members.last + + expect(member.tasks_to_be_done).to match_array([:ci, :code]) + expect(member.member_task.project).to eq(task_project) + end + end end end @@ -379,5 +395,20 @@ RSpec.shared_examples_for "bulk member creation" do expect(members).to all(be_persisted) end end + + context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do + before do + stub_experiments(invite_members_for_task: true) + end + + it 'creates a member_task with the correct attributes', :aggregate_failures do + task_project = source.is_a?(Group) ? create(:project, group: source) : source + members = described_class.add_users(source, [user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id) + member = members.last + + expect(member.tasks_to_be_done).to match_array([:ci, :code]) + expect(member.member_task.project).to eq(task_project) + end + end end end diff --git a/spec/support/shared_examples/models/reviewer_state_shared_examples.rb b/spec/support/shared_examples/models/reviewer_state_shared_examples.rb new file mode 100644 index 00000000000..f1392768b06 --- /dev/null +++ b/spec/support/shared_examples/models/reviewer_state_shared_examples.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'having reviewer state' do + describe 'mr_attention_requests feature flag is disabled' do + before do + stub_feature_flags(mr_attention_requests: false) + end + + it { is_expected.to have_attributes(state: 'unreviewed') } + end + + describe 'mr_attention_requests feature flag is enabled' do + it { is_expected.to have_attributes(state: 'attention_requested') } + end +end diff --git a/spec/support/shared_examples/namespaces/traversal_examples.rb b/spec/support/shared_examples/namespaces/traversal_examples.rb index d126b242fb0..ac6a843663f 100644 --- a/spec/support/shared_examples/namespaces/traversal_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_examples.rb @@ -22,6 +22,8 @@ RSpec.shared_examples 'namespace traversal' do let_it_be(:deep_nested_group) { create(:group, parent: nested_group) } let_it_be(:very_deep_nested_group) { create(:group, parent: deep_nested_group) } let_it_be(:groups) { [group, nested_group, deep_nested_group, very_deep_nested_group] } + let_it_be(:project) { create(:project, group: nested_group) } + let_it_be(:project_namespace) { project.project_namespace } describe '#root_ancestor' do it 'returns the correct root ancestor' do @@ -65,6 +67,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.ancestors).to contain_exactly(group, nested_group) expect(nested_group.ancestors).to contain_exactly(group) expect(group.ancestors).to eq([]) + expect(project_namespace.ancestors).to be_empty end context 'with asc hierarchy_order' do @@ -73,6 +76,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.ancestors(hierarchy_order: :asc)).to eq [nested_group, group] expect(nested_group.ancestors(hierarchy_order: :asc)).to eq [group] expect(group.ancestors(hierarchy_order: :asc)).to eq([]) + expect(project_namespace.ancestors(hierarchy_order: :asc)).to be_empty end end @@ -82,6 +86,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.ancestors(hierarchy_order: :desc)).to eq [group, nested_group] expect(nested_group.ancestors(hierarchy_order: :desc)).to eq [group] expect(group.ancestors(hierarchy_order: :desc)).to eq([]) + expect(project_namespace.ancestors(hierarchy_order: :desc)).to be_empty end end @@ -98,6 +103,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.ancestor_ids).to contain_exactly(group.id, nested_group.id) expect(nested_group.ancestor_ids).to contain_exactly(group.id) expect(group.ancestor_ids).to be_empty + expect(project_namespace.ancestor_ids).to be_empty end context 'with asc hierarchy_order' do @@ -106,6 +112,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.ancestor_ids(hierarchy_order: :asc)).to eq [nested_group.id, group.id] expect(nested_group.ancestor_ids(hierarchy_order: :asc)).to eq [group.id] expect(group.ancestor_ids(hierarchy_order: :asc)).to eq([]) + expect(project_namespace.ancestor_ids(hierarchy_order: :asc)).to eq([]) end end @@ -115,6 +122,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.ancestor_ids(hierarchy_order: :desc)).to eq [group.id, nested_group.id] expect(nested_group.ancestor_ids(hierarchy_order: :desc)).to eq [group.id] expect(group.ancestor_ids(hierarchy_order: :desc)).to eq([]) + expect(project_namespace.ancestor_ids(hierarchy_order: :desc)).to eq([]) end end @@ -131,6 +139,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.self_and_ancestors).to contain_exactly(group, nested_group, deep_nested_group) expect(nested_group.self_and_ancestors).to contain_exactly(group, nested_group) expect(group.self_and_ancestors).to contain_exactly(group) + expect(project_namespace.self_and_ancestors).to contain_exactly(project_namespace) end context 'with asc hierarchy_order' do @@ -139,6 +148,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.self_and_ancestors(hierarchy_order: :asc)).to eq [deep_nested_group, nested_group, group] expect(nested_group.self_and_ancestors(hierarchy_order: :asc)).to eq [nested_group, group] expect(group.self_and_ancestors(hierarchy_order: :asc)).to eq([group]) + expect(project_namespace.self_and_ancestors(hierarchy_order: :asc)).to eq([project_namespace]) end end @@ -148,6 +158,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.self_and_ancestors(hierarchy_order: :desc)).to eq [group, nested_group, deep_nested_group] expect(nested_group.self_and_ancestors(hierarchy_order: :desc)).to eq [group, nested_group] expect(group.self_and_ancestors(hierarchy_order: :desc)).to eq([group]) + expect(project_namespace.self_and_ancestors(hierarchy_order: :desc)).to eq([project_namespace]) end end @@ -164,6 +175,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.self_and_ancestor_ids).to contain_exactly(group.id, nested_group.id, deep_nested_group.id) expect(nested_group.self_and_ancestor_ids).to contain_exactly(group.id, nested_group.id) expect(group.self_and_ancestor_ids).to contain_exactly(group.id) + expect(project_namespace.self_and_ancestor_ids).to contain_exactly(project_namespace.id) end context 'with asc hierarchy_order' do @@ -172,6 +184,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.self_and_ancestor_ids(hierarchy_order: :asc)).to eq [deep_nested_group.id, nested_group.id, group.id] expect(nested_group.self_and_ancestor_ids(hierarchy_order: :asc)).to eq [nested_group.id, group.id] expect(group.self_and_ancestor_ids(hierarchy_order: :asc)).to eq([group.id]) + expect(project_namespace.self_and_ancestor_ids(hierarchy_order: :asc)).to eq([project_namespace.id]) end end @@ -181,6 +194,7 @@ RSpec.shared_examples 'namespace traversal' do expect(deep_nested_group.self_and_ancestor_ids(hierarchy_order: :desc)).to eq [group.id, nested_group.id, deep_nested_group.id] expect(nested_group.self_and_ancestor_ids(hierarchy_order: :desc)).to eq [group.id, nested_group.id] expect(group.self_and_ancestor_ids(hierarchy_order: :desc)).to eq([group.id]) + expect(project_namespace.self_and_ancestor_ids(hierarchy_order: :desc)).to eq([project_namespace.id]) end end @@ -205,6 +219,10 @@ RSpec.shared_examples 'namespace traversal' do describe '#recursive_descendants' do it_behaves_like 'recursive version', :descendants end + + it 'does not include project namespaces' do + expect(group.descendants.to_a).not_to include(project_namespace) + end end describe '#self_and_descendants' do @@ -223,6 +241,10 @@ RSpec.shared_examples 'namespace traversal' do it_behaves_like 'recursive version', :self_and_descendants end + + it 'does not include project namespaces' do + expect(group.self_and_descendants.to_a).not_to include(project_namespace) + end end describe '#self_and_descendant_ids' do diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb index 74b1bacc560..4c09c1c2a3b 100644 --- a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb @@ -25,12 +25,6 @@ RSpec.shared_examples 'namespace traversal scopes' do it { is_expected.to contain_exactly(group_1.id, group_2.id) } end - describe '.without_sti_condition' do - subject { described_class.without_sti_condition } - - it { expect(subject.where_values_hash).not_to have_key(:type) } - end - describe '.order_by_depth' do subject { described_class.where(id: [group_1, nested_group_1, deep_nested_group_1]).order_by_depth(direction) } @@ -55,6 +49,53 @@ RSpec.shared_examples 'namespace traversal scopes' do it { is_expected.to eq described_class.column_names } end + shared_examples '.roots' do + context 'with only sub-groups' do + subject { described_class.where(id: [deep_nested_group_1, nested_group_1, deep_nested_group_2]).roots } + + it { is_expected.to contain_exactly(group_1, group_2) } + end + + context 'with only root groups' do + subject { described_class.where(id: [group_1, group_2]).roots } + + it { is_expected.to contain_exactly(group_1, group_2) } + end + + context 'with all groups' do + subject { described_class.where(id: groups).roots } + + it { is_expected.to contain_exactly(group_1, group_2) } + end + end + + describe '.roots' do + context "use_traversal_ids_roots feature flag is true" do + before do + stub_feature_flags(use_traversal_ids: true) + stub_feature_flags(use_traversal_ids_roots: true) + end + + it_behaves_like '.roots' + + it 'not make recursive queries' do + expect { described_class.where(id: [nested_group_1]).roots.load }.not_to make_queries_matching(/WITH RECURSIVE/) + end + end + + context "use_traversal_ids_roots feature flag is false" do + before do + stub_feature_flags(use_traversal_ids_roots: false) + end + + it_behaves_like '.roots' + + it 'make recursive queries' do + expect { described_class.where(id: [nested_group_1]).roots.load }.to make_queries_matching(/WITH RECURSIVE/) + end + end + end + shared_examples '.self_and_ancestors' do subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors } @@ -156,7 +197,7 @@ RSpec.shared_examples 'namespace traversal scopes' do end end - describe '.self_and_descendants' do + shared_examples '.self_and_descendants' do subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_descendants } it { is_expected.to contain_exactly(nested_group_1, deep_nested_group_1, nested_group_2, deep_nested_group_2) } @@ -174,7 +215,19 @@ RSpec.shared_examples 'namespace traversal scopes' do end end - describe '.self_and_descendant_ids' do + describe '.self_and_descendants' do + include_examples '.self_and_descendants' + + context 'with traversal_ids_btree feature flag disabled' do + before do + stub_feature_flags(traversal_ids_btree: false) + end + + include_examples '.self_and_descendants' + end + end + + shared_examples '.self_and_descendant_ids' do subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_descendant_ids.pluck(:id) } it { is_expected.to contain_exactly(nested_group_1.id, deep_nested_group_1.id, nested_group_2.id, deep_nested_group_2.id) } @@ -190,4 +243,16 @@ RSpec.shared_examples 'namespace traversal scopes' do it { is_expected.to contain_exactly(deep_nested_group_1.id, deep_nested_group_2.id) } end end + + describe '.self_and_descendant_ids' do + include_examples '.self_and_descendant_ids' + + context 'with traversal_ids_btree feature flag disabled' do + before do + stub_feature_flags(traversal_ids_btree: false) + end + + include_examples '.self_and_descendant_ids' + end + end end diff --git a/spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb new file mode 100644 index 00000000000..5167d27f8b9 --- /dev/null +++ b/spec/support/shared_examples/quick_actions/issue/promote_to_incident_quick_action_shared_examples.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'promote_to_incident quick action' do + describe '/promote_to_incident' do + context 'when issue can be promoted' do + it 'promotes issue to incident' do + add_note('/promote_to_incident') + + expect(issue.reload.issue_type).to eq('incident') + expect(page).to have_content('Issue has been promoted to incident') + end + end + + context 'when issue is already an incident' do + let(:issue) { create(:incident, project: project) } + + it 'does not promote the issue' do + add_note('/promote_to_incident') + + expect(page).to have_content('Could not apply promote_to_incident command') + end + end + + context 'when user does not have permissions' do + let(:guest) { create(:user) } + + before do + sign_in(guest) + visit project_issue_path(project, issue) + wait_for_all_requests + end + + it 'does not promote the issue' do + add_note('/promote_to_incident') + + expect(page).to have_content('Could not apply promote_to_incident command') + end + end + end +end diff --git a/spec/support/shared_examples/requests/api/debian_common_shared_examples.rb b/spec/support/shared_examples/requests/api/debian_common_shared_examples.rb new file mode 100644 index 00000000000..e0225070986 --- /dev/null +++ b/spec/support/shared_examples/requests/api/debian_common_shared_examples.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'rejects Debian access with unknown container id' do |anonymous_status, auth_method| + context 'with an unknown container' do + let(:container) { double(id: non_existing_record_id) } + + context 'as anonymous' do + it_behaves_like 'Debian packages GET request', anonymous_status, nil + end + + context 'as authenticated user' do + include_context 'Debian repository auth headers', :not_a_member, auth_method do + it_behaves_like 'Debian packages GET request', :not_found, nil + end + end + end +end diff --git a/spec/support/shared_examples/requests/api/debian_distributions_shared_examples.rb b/spec/support/shared_examples/requests/api/debian_distributions_shared_examples.rb new file mode 100644 index 00000000000..5cd63c33936 --- /dev/null +++ b/spec/support/shared_examples/requests/api/debian_distributions_shared_examples.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'Debian distributions GET request' do |status, body = nil| + and_body = body.nil? ? '' : ' and expected body' + + it "returns #{status}#{and_body}" do + subject + + expect(response).to have_gitlab_http_status(status) + + unless body.nil? + expect(response.body).to match(body) + end + end +end + +RSpec.shared_examples 'Debian distributions PUT request' do |status, body| + and_body = body.nil? ? '' : ' and expected body' + + if status == :success + it 'updates distribution', :aggregate_failures do + expect(::Packages::Debian::UpdateDistributionService).to receive(:new).with(distribution, api_params.except(:codename)).and_call_original + + expect { subject } + .to not_change { Packages::Debian::GroupDistribution.all.count + Packages::Debian::ProjectDistribution.all.count } + .and not_change { Packages::Debian::GroupComponent.all.count + Packages::Debian::ProjectComponent.all.count } + .and not_change { Packages::Debian::GroupArchitecture.all.count + Packages::Debian::ProjectArchitecture.all.count } + + expect(response).to have_gitlab_http_status(status) + expect(response.media_type).to eq('application/json') + + unless body.nil? + expect(response.body).to match(body) + end + end + else + it "returns #{status}#{and_body}", :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(status) + + unless body.nil? + expect(response.body).to match(body) + end + end + end +end + +RSpec.shared_examples 'Debian distributions DELETE request' do |status, body| + and_body = body.nil? ? '' : ' and expected body' + + if status == :success + it 'updates distribution', :aggregate_failures do + expect { subject } + .to change { Packages::Debian::GroupDistribution.all.count + Packages::Debian::ProjectDistribution.all.count }.by(-1) + .and change { Packages::Debian::GroupComponent.all.count + Packages::Debian::ProjectComponent.all.count }.by(-1) + .and change { Packages::Debian::GroupArchitecture.all.count + Packages::Debian::ProjectArchitecture.all.count }.by(-2) + + expect(response).to have_gitlab_http_status(status) + expect(response.media_type).to eq('application/json') + + unless body.nil? + expect(response.body).to match(body) + end + end + else + it "returns #{status}#{and_body}", :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(status) + + unless body.nil? + expect(response.body).to match(body) + end + end + end +end + +RSpec.shared_examples 'Debian distributions POST request' do |status, body| + and_body = body.nil? ? '' : ' and expected body' + + if status == :created + it 'creates distribution', :aggregate_failures do + expect(::Packages::Debian::CreateDistributionService).to receive(:new).with(container, user, api_params).and_call_original + + expect { subject } + .to change { Packages::Debian::GroupDistribution.all.count + Packages::Debian::ProjectDistribution.all.count }.by(1) + .and change { Packages::Debian::GroupComponent.all.count + Packages::Debian::ProjectComponent.all.count }.by(1) + .and change { Packages::Debian::GroupArchitecture.all.count + Packages::Debian::ProjectArchitecture.all.count }.by(2) + + expect(response).to have_gitlab_http_status(status) + expect(response.media_type).to eq('application/json') + + unless body.nil? + expect(response.body).to match(body) + end + end + else + it "returns #{status}#{and_body}", :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(status) + + unless body.nil? + expect(response.body).to match(body) + end + end + end +end + +RSpec.shared_examples 'Debian distributions read endpoint' do |desired_behavior, success_status, success_body| + context 'with valid container' do + using RSpec::Parameterized::TableSyntax + + where(:visibility_level, :user_type, :auth_method, :expected_status, :expected_body) do + :public | :guest | :private_token | success_status | success_body + :public | :not_a_member | :private_token | success_status | success_body + :public | :anonymous | :private_token | success_status | success_body + :public | :invalid_token | :private_token | :unauthorized | nil + :private | :developer | :private_token | success_status | success_body + :private | :developer | :basic | :not_found | nil + :private | :guest | :private_token | :forbidden | nil + :private | :not_a_member | :private_token | :not_found | nil + :private | :anonymous | :private_token | :not_found | nil + :private | :invalid_token | :private_token | :unauthorized | nil + end + + with_them do + include_context 'Debian repository access', params[:visibility_level], params[:user_type], params[:auth_method] do + it_behaves_like "Debian distributions #{desired_behavior} request", params[:expected_status], params[:expected_body] + end + end + end + + it_behaves_like 'rejects Debian access with unknown container id', :not_found, :private_token +end + +RSpec.shared_examples 'Debian distributions write endpoint' do |desired_behavior, success_status, success_body| + context 'with valid container' do + using RSpec::Parameterized::TableSyntax + + where(:visibility_level, :user_type, :auth_method, :expected_status, :expected_body) do + :public | :developer | :private_token | success_status | success_body + :public | :developer | :basic | :unauthorized | nil + :public | :guest | :private_token | :forbidden | nil + :public | :not_a_member | :private_token | :forbidden | nil + :public | :anonymous | :private_token | :unauthorized | nil + :public | :invalid_token | :private_token | :unauthorized | nil + :private | :developer | :private_token | success_status | success_body + :private | :guest | :private_token | :forbidden | nil + :private | :not_a_member | :private_token | :not_found | nil + :private | :anonymous | :private_token | :not_found | nil + :private | :invalid_token | :private_token | :unauthorized | nil + end + + with_them do + include_context 'Debian repository access', params[:visibility_level], params[:user_type], params[:auth_method] do + it_behaves_like "Debian distributions #{desired_behavior} request", params[:expected_status], params[:expected_body] + end + end + end + + it_behaves_like 'rejects Debian access with unknown container id', :not_found, :private_token +end + +RSpec.shared_examples 'Debian distributions maintainer write endpoint' do |desired_behavior, success_status, success_body| + context 'with valid container' do + using RSpec::Parameterized::TableSyntax + + where(:visibility_level, :user_type, :auth_method, :expected_status, :expected_body) do + :public | :maintainer | :private_token | success_status | success_body + :public | :maintainer | :basic | :unauthorized | nil + :public | :developer | :private_token | :forbidden | nil + :public | :not_a_member | :private_token | :forbidden | nil + :public | :anonymous | :private_token | :unauthorized | nil + :public | :invalid_token | :private_token | :unauthorized | nil + :private | :maintainer | :private_token | success_status | success_body + :private | :developer | :private_token | :forbidden | nil + :private | :not_a_member | :private_token | :not_found | nil + :private | :anonymous | :private_token | :not_found | nil + :private | :invalid_token | :private_token | :unauthorized | nil + end + + with_them do + include_context 'Debian repository access', params[:visibility_level], params[:user_type], params[:auth_method] do + it_behaves_like "Debian distributions #{desired_behavior} request", params[:expected_status], params[:expected_body] + end + end + end + + it_behaves_like 'rejects Debian access with unknown container id', :not_found, :private_token +end diff --git a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb index a3ed74085fb..2fd5e6a5f91 100644 --- a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb @@ -1,127 +1,6 @@ # frozen_string_literal: true -RSpec.shared_context 'Debian repository shared context' do |container_type, can_freeze| - include_context 'workhorse headers' - - before do - stub_feature_flags(debian_packages: true, debian_group_packages: true) - end - - let_it_be(:private_container, freeze: can_freeze) { create(container_type, :private) } - let_it_be(:public_container, freeze: can_freeze) { create(container_type, :public) } - let_it_be(:user, freeze: true) { create(:user) } - let_it_be(:personal_access_token, freeze: true) { create(:personal_access_token, user: user) } - - let_it_be(:private_distribution, freeze: true) { create("debian_#{container_type}_distribution", :with_file, container: private_container, codename: 'existing-codename') } - let_it_be(:private_component, freeze: true) { create("debian_#{container_type}_component", distribution: private_distribution, name: 'existing-component') } - let_it_be(:private_architecture_all, freeze: true) { create("debian_#{container_type}_architecture", distribution: private_distribution, name: 'all') } - let_it_be(:private_architecture, freeze: true) { create("debian_#{container_type}_architecture", distribution: private_distribution, name: 'existing-arch') } - let_it_be(:private_component_file) { create("debian_#{container_type}_component_file", component: private_component, architecture: private_architecture) } - - let_it_be(:public_distribution, freeze: true) { create("debian_#{container_type}_distribution", :with_file, container: public_container, codename: 'existing-codename') } - let_it_be(:public_component, freeze: true) { create("debian_#{container_type}_component", distribution: public_distribution, name: 'existing-component') } - let_it_be(:public_architecture_all, freeze: true) { create("debian_#{container_type}_architecture", distribution: public_distribution, name: 'all') } - let_it_be(:public_architecture, freeze: true) { create("debian_#{container_type}_architecture", distribution: public_distribution, name: 'existing-arch') } - let_it_be(:public_component_file) { create("debian_#{container_type}_component_file", component: public_component, architecture: public_architecture) } - - if container_type == :group - let_it_be(:private_project) { create(:project, :private, group: private_container) } - let_it_be(:public_project) { create(:project, :public, group: public_container) } - let_it_be(:private_project_distribution) { create(:debian_project_distribution, container: private_project, codename: 'existing-codename') } - let_it_be(:public_project_distribution) { create(:debian_project_distribution, container: public_project, codename: 'existing-codename') } - - let(:project) { { private: private_project, public: public_project }[visibility_level] } - else - let_it_be(:private_project) { private_container } - let_it_be(:public_project) { public_container } - let_it_be(:private_project_distribution) { private_distribution } - let_it_be(:public_project_distribution) { public_distribution } - end - - let_it_be(:private_package) { create(:debian_package, project: private_project, published_in: private_project_distribution) } - let_it_be(:public_package) { create(:debian_package, project: public_project, published_in: public_project_distribution) } - - let(:visibility_level) { :public } - - let(:distribution) { { private: private_distribution, public: public_distribution }[visibility_level] } - let(:architecture) { { private: private_architecture, public: public_architecture }[visibility_level] } - let(:component) { { private: private_component, public: public_component }[visibility_level] } - let(:component_file) { { private: private_component_file, public: public_component_file }[visibility_level] } - let(:package) { { private: private_package, public: public_package }[visibility_level] } - let(:letter) { package.name[0..2] == 'lib' ? package.name[0..3] : package.name[0] } - - let(:method) { :get } - - let(:workhorse_params) do - if method == :put - file_upload = fixture_file_upload("spec/fixtures/packages/debian/#{file_name}") - { file: file_upload } - else - {} - end - end - - let(:api_params) { workhorse_params } - - let(:auth_headers) { {} } - let(:wh_headers) do - if method == :put - workhorse_headers - else - {} - end - end - - let(:headers) { auth_headers.merge(wh_headers) } - - let(:send_rewritten_field) { true } - - subject do - if method == :put - workhorse_finalize( - api(url), - method: method, - file_key: :file, - params: api_params, - headers: headers, - send_rewritten_field: send_rewritten_field - ) - else - send method, api(url), headers: headers, params: api_params - end - end -end - -RSpec.shared_context 'with file_name' do |file_name| - let(:file_name) { file_name } -end - -RSpec.shared_context 'Debian repository auth headers' do |user_role, user_token, auth_method = :token| - let(:token) { user_token ? personal_access_token.token : 'wrong' } - - let(:auth_headers) do - if user_role == :anonymous - {} - elsif auth_method == :token - { 'Private-Token' => token } - else - basic_auth_header(user.username, token) - end - end -end - -RSpec.shared_context 'Debian repository access' do |visibility_level, user_role, add_member, user_token, auth_method| - include_context 'Debian repository auth headers', user_role, user_token, auth_method do - let(:containers) { { private: private_container, public: public_container } } - let(:container) { containers[visibility_level] } - - before do - container.send("add_#{user_role}", user) if add_member && user_role != :anonymous - end - end -end - -RSpec.shared_examples 'Debian repository GET request' do |status, body = nil| +RSpec.shared_examples 'Debian packages GET request' do |status, body = nil| and_body = body.nil? ? '' : ' and expected body' it "returns #{status}#{and_body}" do @@ -135,7 +14,7 @@ RSpec.shared_examples 'Debian repository GET request' do |status, body = nil| end end -RSpec.shared_examples 'Debian repository upload request' do |status, body = nil| +RSpec.shared_examples 'Debian packages upload request' do |status, body = nil| and_body = body.nil? ? '' : ' and expected body' if status == :created @@ -175,7 +54,7 @@ RSpec.shared_examples 'Debian repository upload request' do |status, body = nil| end end -RSpec.shared_examples 'Debian repository upload authorize request' do |status, body = nil| +RSpec.shared_examples 'Debian packages upload authorize request' do |status, body = nil| and_body = body.nil? ? '' : ' and expected body' if status == :created @@ -221,237 +100,57 @@ RSpec.shared_examples 'Debian repository upload authorize request' do |status, b end end -RSpec.shared_examples 'Debian repository POST distribution request' do |status, body| - and_body = body.nil? ? '' : ' and expected body' - - if status == :created - it 'creates distribution', :aggregate_failures do - expect(::Packages::Debian::CreateDistributionService).to receive(:new).with(container, user, api_params).and_call_original - - expect { subject } - .to change { Packages::Debian::GroupDistribution.all.count + Packages::Debian::ProjectDistribution.all.count }.by(1) - .and change { Packages::Debian::GroupComponent.all.count + Packages::Debian::ProjectComponent.all.count }.by(1) - .and change { Packages::Debian::GroupArchitecture.all.count + Packages::Debian::ProjectArchitecture.all.count }.by(2) - - expect(response).to have_gitlab_http_status(status) - expect(response.media_type).to eq('application/json') - - unless body.nil? - expect(response.body).to match(body) - end - end - else - it "returns #{status}#{and_body}", :aggregate_failures do - subject - - expect(response).to have_gitlab_http_status(status) - - unless body.nil? - expect(response.body).to match(body) - end - end - end -end - -RSpec.shared_examples 'Debian repository PUT distribution request' do |status, body| - and_body = body.nil? ? '' : ' and expected body' - - if status == :success - it 'updates distribution', :aggregate_failures do - expect(::Packages::Debian::UpdateDistributionService).to receive(:new).with(distribution, api_params.except(:codename)).and_call_original - - expect { subject } - .to not_change { Packages::Debian::GroupDistribution.all.count + Packages::Debian::ProjectDistribution.all.count } - .and not_change { Packages::Debian::GroupComponent.all.count + Packages::Debian::ProjectComponent.all.count } - .and not_change { Packages::Debian::GroupArchitecture.all.count + Packages::Debian::ProjectArchitecture.all.count } - - expect(response).to have_gitlab_http_status(status) - expect(response.media_type).to eq('application/json') - - unless body.nil? - expect(response.body).to match(body) - end - end - else - it "returns #{status}#{and_body}", :aggregate_failures do - subject - - expect(response).to have_gitlab_http_status(status) - - unless body.nil? - expect(response.body).to match(body) - end - end - end -end - -RSpec.shared_examples 'Debian repository DELETE distribution request' do |status, body| - and_body = body.nil? ? '' : ' and expected body' - - if status == :success - it 'updates distribution', :aggregate_failures do - expect { subject } - .to change { Packages::Debian::GroupDistribution.all.count + Packages::Debian::ProjectDistribution.all.count }.by(-1) - .and change { Packages::Debian::GroupComponent.all.count + Packages::Debian::ProjectComponent.all.count }.by(-1) - .and change { Packages::Debian::GroupArchitecture.all.count + Packages::Debian::ProjectArchitecture.all.count }.by(-2) - - expect(response).to have_gitlab_http_status(status) - expect(response.media_type).to eq('application/json') - - unless body.nil? - expect(response.body).to match(body) - end - end - else - it "returns #{status}#{and_body}", :aggregate_failures do - subject - - expect(response).to have_gitlab_http_status(status) - - unless body.nil? - expect(response.body).to match(body) - end - end - end -end - -RSpec.shared_examples 'rejects Debian access with unknown container id' do |hidden_status| - context 'with an unknown container' do - let(:container) { double(id: non_existing_record_id) } - - context 'as anonymous' do - it_behaves_like 'Debian repository GET request', hidden_status, nil - end - - context 'as authenticated user' do - subject { get api(url), headers: basic_auth_header(user.username, personal_access_token.token) } - - it_behaves_like 'Debian repository GET request', :not_found, nil - end - end -end - -RSpec.shared_examples 'Debian repository read endpoint' do |desired_behavior, success_status, success_body, authenticate_non_public: true| - hidden_status = if authenticate_non_public - :unauthorized - else - :not_found - end - - context 'with valid container' do - using RSpec::Parameterized::TableSyntax - - where(:visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do - :public | :developer | true | true | success_status | success_body - :public | :guest | true | true | success_status | success_body - :public | :developer | true | false | :unauthorized | nil - :public | :guest | true | false | :unauthorized | nil - :public | :developer | false | true | success_status | success_body - :public | :guest | false | true | success_status | success_body - :public | :developer | false | false | :unauthorized | nil - :public | :guest | false | false | :unauthorized | nil - :public | :anonymous | false | true | success_status | success_body - :private | :developer | true | true | success_status | success_body - :private | :guest | true | true | :forbidden | nil - :private | :developer | true | false | :unauthorized | nil - :private | :guest | true | false | :unauthorized | nil - :private | :developer | false | true | :not_found | nil - :private | :guest | false | true | :not_found | nil - :private | :developer | false | false | :unauthorized | nil - :private | :guest | false | false | :unauthorized | nil - :private | :anonymous | false | true | hidden_status | nil - end - - with_them do - include_context 'Debian repository access', params[:visibility_level], params[:user_role], params[:member], params[:user_token], :basic do - it_behaves_like "Debian repository #{desired_behavior}", params[:expected_status], params[:expected_body] - end - end - end - - it_behaves_like 'rejects Debian access with unknown container id', hidden_status -end - -RSpec.shared_examples 'Debian repository write endpoint' do |desired_behavior, success_status, success_body, authenticate_non_public: true| - hidden_status = if authenticate_non_public - :unauthorized - else - :not_found - end - +RSpec.shared_examples 'Debian packages read endpoint' do |desired_behavior, success_status, success_body| context 'with valid container' do using RSpec::Parameterized::TableSyntax - where(:visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do - :public | :developer | true | true | success_status | success_body - :public | :guest | true | true | :forbidden | nil - :public | :developer | true | false | :unauthorized | nil - :public | :guest | true | false | :unauthorized | nil - :public | :developer | false | true | :forbidden | nil - :public | :guest | false | true | :forbidden | nil - :public | :developer | false | false | :unauthorized | nil - :public | :guest | false | false | :unauthorized | nil - :public | :anonymous | false | true | :unauthorized | nil - :private | :developer | true | true | success_status | success_body - :private | :guest | true | true | :forbidden | nil - :private | :developer | true | false | :unauthorized | nil - :private | :guest | true | false | :unauthorized | nil - :private | :developer | false | true | :not_found | nil - :private | :guest | false | true | :not_found | nil - :private | :developer | false | false | :unauthorized | nil - :private | :guest | false | false | :unauthorized | nil - :private | :anonymous | false | true | hidden_status | nil + where(:visibility_level, :user_type, :auth_method, :expected_status, :expected_body) do + :public | :guest | :basic | success_status | success_body + :public | :not_a_member | :basic | success_status | success_body + :public | :anonymous | :basic | success_status | success_body + :public | :invalid_token | :basic | :unauthorized | nil + :private | :developer | :basic | success_status | success_body + :private | :developer | :private_token | :unauthorized | nil + :private | :guest | :basic | :forbidden | nil + :private | :not_a_member | :basic | :not_found | nil + :private | :anonymous | :basic | :unauthorized | nil + :private | :invalid_token | :basic | :unauthorized | nil end with_them do - include_context 'Debian repository access', params[:visibility_level], params[:user_role], params[:member], params[:user_token], :basic do - it_behaves_like "Debian repository #{desired_behavior}", params[:expected_status], params[:expected_body] + include_context 'Debian repository access', params[:visibility_level], params[:user_type], params[:auth_method] do + it_behaves_like "Debian packages #{desired_behavior} request", params[:expected_status], params[:expected_body] end end end - it_behaves_like 'rejects Debian access with unknown container id', hidden_status + it_behaves_like 'rejects Debian access with unknown container id', :unauthorized, :basic end -RSpec.shared_examples 'Debian repository maintainer write endpoint' do |desired_behavior, success_status, success_body, authenticate_non_public: true| - hidden_status = if authenticate_non_public - :unauthorized - else - :not_found - end - +RSpec.shared_examples 'Debian packages write endpoint' do |desired_behavior, success_status, success_body| context 'with valid container' do using RSpec::Parameterized::TableSyntax - where(:visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do - :public | :maintainer | true | true | success_status | success_body - :public | :developer | true | true | :forbidden | nil - :public | :guest | true | true | :forbidden | nil - :public | :maintainer | true | false | :unauthorized | nil - :public | :guest | true | false | :unauthorized | nil - :public | :maintainer | false | true | :forbidden | nil - :public | :guest | false | true | :forbidden | nil - :public | :maintainer | false | false | :unauthorized | nil - :public | :guest | false | false | :unauthorized | nil - :public | :anonymous | false | true | :unauthorized | nil - :private | :maintainer | true | true | success_status | success_body - :private | :developer | true | true | :forbidden | nil - :private | :guest | true | true | :forbidden | nil - :private | :maintainer | true | false | :unauthorized | nil - :private | :guest | true | false | :unauthorized | nil - :private | :maintainer | false | true | :not_found | nil - :private | :guest | false | true | :not_found | nil - :private | :maintainer | false | false | :unauthorized | nil - :private | :guest | false | false | :unauthorized | nil - :private | :anonymous | false | true | hidden_status | nil + where(:visibility_level, :user_type, :auth_method, :expected_status, :expected_body) do + :public | :developer | :basic | success_status | success_body + :public | :developer | :private_token | :unauthorized | nil + :public | :guest | :basic | :forbidden | nil + :public | :not_a_member | :basic | :forbidden | nil + :public | :anonymous | :basic | :unauthorized | nil + :public | :invalid_token | :basic | :unauthorized | nil + :private | :developer | :basic | success_status | success_body + :private | :guest | :basic | :forbidden | nil + :private | :not_a_member | :basic | :not_found | nil + :private | :anonymous | :basic | :unauthorized | nil + :private | :invalid_token | :basic | :unauthorized | nil end with_them do - include_context 'Debian repository access', params[:visibility_level], params[:user_role], params[:member], params[:user_token], :basic do - it_behaves_like "Debian repository #{desired_behavior}", params[:expected_status], params[:expected_body] + include_context 'Debian repository access', params[:visibility_level], params[:user_type], params[:auth_method] do + it_behaves_like "Debian packages #{desired_behavior} request", params[:expected_status], params[:expected_body] end end end - it_behaves_like 'rejects Debian access with unknown container id', hidden_status + it_behaves_like 'rejects Debian access with unknown container id', :unauthorized, :basic end diff --git a/spec/support/shared_examples/requests/api/graphql/mutations/destroy_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/mutations/destroy_list_shared_examples.rb index 0cec67ff541..dca152223fb 100644 --- a/spec/support/shared_examples/requests/api/graphql/mutations/destroy_list_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/mutations/destroy_list_shared_examples.rb @@ -28,7 +28,7 @@ RSpec.shared_examples 'board lists destroy request' do it 'returns an error' do subject - expect(graphql_errors.first['message']).to include("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + expect(graphql_errors.first['message']).to include(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) end end diff --git a/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb index 41a61ba5fd7..d576a5874fd 100644 --- a/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb @@ -2,12 +2,26 @@ RSpec.shared_examples 'a package detail' do it_behaves_like 'a working graphql query' do - it 'matches the JSON schema' do - expect(package_details).to match_schema('graphql/packages/package_details') + it_behaves_like 'matching the package details schema' + end + + context 'with pipelines' do + let_it_be(:build_info1) { create(:package_build_info, :with_pipeline, package: package) } + let_it_be(:build_info2) { create(:package_build_info, :with_pipeline, package: package) } + let_it_be(:build_info3) { create(:package_build_info, :with_pipeline, package: package) } + + it_behaves_like 'a working graphql query' do + it_behaves_like 'matching the package details schema' end end end +RSpec.shared_examples 'matching the package details schema' do + it 'matches the JSON schema' do + expect(package_details).to match_schema('graphql/packages/package_details') + end +end + RSpec.shared_examples 'a package with files' do it 'has the right amount of files' do expect(package_files_response.length).to be(package.package_files.length) diff --git a/spec/support/shared_examples/requests/api/notes_shared_examples.rb b/spec/support/shared_examples/requests/api/notes_shared_examples.rb index 40799688144..0434d0beb7e 100644 --- a/spec/support/shared_examples/requests/api/notes_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/notes_shared_examples.rb @@ -281,7 +281,7 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name| end end - context 'when request exceeds the rate limit' do + context 'when request exceeds the rate limit', :freeze_time, :clean_gitlab_redis_rate_limiting do before do stub_application_setting(notes_create_limit: 1) allow(::Gitlab::ApplicationRateLimiter).to receive(:increment).and_return(2) diff --git a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb index 2af7b616659..19677e92001 100644 --- a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb @@ -8,6 +8,8 @@ RSpec.shared_examples 'handling get metadata requests' do |scope: :project| let_it_be(:package_dependency_link3) { create(:packages_dependency_link, package: package, dependency_type: :bundleDependencies) } let_it_be(:package_dependency_link4) { create(:packages_dependency_link, package: package, dependency_type: :peerDependencies) } + let_it_be(:package_metadatum) { create(:npm_metadatum, package: package) } + let(:headers) { {} } subject { get(url, headers: headers) } @@ -39,6 +41,19 @@ RSpec.shared_examples 'handling get metadata requests' do |scope: :project| # query count can slightly change between the examples so we're using a custom threshold expect { get(url, headers: headers) }.not_to exceed_query_limit(control).with_threshold(4) end + + context 'with packages_npm_abbreviated_metadata disabled' do + before do + stub_feature_flags(packages_npm_abbreviated_metadata: false) + end + + it 'calls the presenter without including metadata' do + expect(::Packages::Npm::PackagePresenter) + .to receive(:new).with(anything, anything, include_metadata: false).and_call_original + + subject + end + end end shared_examples 'reject metadata request' do |status:| diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb index ed6d9ed43c8..06c51add438 100644 --- a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb @@ -167,7 +167,7 @@ end RSpec.shared_examples 'rejects PyPI access with unknown project id' do context 'with an unknown project' do - let(:project) { OpenStruct.new(id: 1234567890) } + let(:project) { double('access', id: 1234567890) } it_behaves_like 'unknown PyPI scope id' end @@ -175,7 +175,7 @@ end RSpec.shared_examples 'rejects PyPI access with unknown group id' do context 'with an unknown project' do - let(:group) { OpenStruct.new(id: 1234567890) } + let(:group) { double('access', id: 1234567890) } it_behaves_like 'unknown PyPI scope id' end diff --git a/spec/support/shared_examples/requests/api/status_shared_examples.rb b/spec/support/shared_examples/requests/api/status_shared_examples.rb index 8207190b1dc..40843ccbd15 100644 --- a/spec/support/shared_examples/requests/api/status_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/status_shared_examples.rb @@ -76,3 +76,32 @@ RSpec.shared_examples '412 response' do end end end + +RSpec.shared_examples '422 response' do + let(:message) { nil } + + before do + # Fires the request + request + end + + it 'returns 422' do + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response).to be_an Object + + if message.present? + expect(json_response['message']).to eq(message) + end + end +end + +RSpec.shared_examples '503 response' do + before do + # Fires the request + request + end + + it 'returns 503' do + expect(response).to have_gitlab_http_status(:service_unavailable) + end +end diff --git a/spec/support/shared_examples/requests/applications_controller_shared_examples.rb b/spec/support/shared_examples/requests/applications_controller_shared_examples.rb new file mode 100644 index 00000000000..8f852d42c2c --- /dev/null +++ b/spec/support/shared_examples/requests/applications_controller_shared_examples.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'applications controller - GET #show' do + describe 'GET #show' do + it 'renders template' do + get show_path + + expect(response).to render_template :show + end + + context 'when application is viewed after being created' do + before do + create_application + end + + it 'sets `@created` instance variable to `true`' do + get show_path + + expect(assigns[:created]).to eq(true) + end + end + + context 'when application is reviewed' do + it 'sets `@created` instance variable to `false`' do + get show_path + + expect(assigns[:created]).to eq(false) + end + end + end +end + +RSpec.shared_examples 'applications controller - POST #create' do + it "sets `#{OauthApplications::CREATED_SESSION_KEY}` session key to `true`" do + create_application + + expect(session[OauthApplications::CREATED_SESSION_KEY]).to eq(true) + end +end + +def create_application + create_params = attributes_for(:application, trusted: true, confidential: false, scopes: ['api']) + post create_path, params: { doorkeeper_application: create_params } +end diff --git a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb index ff87fc5d8df..f8a752a5673 100644 --- a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb +++ b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb @@ -39,6 +39,10 @@ end # let(:status_api) { status_create_self_monitoring_project_admin_application_settings_path } # subject { post create_self_monitoring_project_admin_application_settings_path } RSpec.shared_examples 'triggers async worker, returns sidekiq job_id with response accepted' do + before do + allow(worker_class).to receive(:with_status).and_return(worker_class) + end + it 'returns sidekiq job_id of expected length' do subject diff --git a/spec/support/shared_examples/requests/snippet_shared_examples.rb b/spec/support/shared_examples/requests/snippet_shared_examples.rb index dae3a3e74be..b13c4da0bed 100644 --- a/spec/support/shared_examples/requests/snippet_shared_examples.rb +++ b/spec/support/shared_examples/requests/snippet_shared_examples.rb @@ -86,6 +86,7 @@ RSpec.shared_examples 'snippet blob content' do expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq 'true' expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + expect(response.parsed_body).to be_empty end context 'when snippet repository is empty' do diff --git a/spec/support/shared_examples/service_desk_issue_templates_examples.rb b/spec/support/shared_examples/service_desk_issue_templates_examples.rb index fd9645df7a3..ed6c5199936 100644 --- a/spec/support/shared_examples/service_desk_issue_templates_examples.rb +++ b/spec/support/shared_examples/service_desk_issue_templates_examples.rb @@ -3,10 +3,10 @@ RSpec.shared_examples 'issue description templates from current project only' do it 'loads issue description templates from the project only' do within('#service-desk-template-select') do - expect(page).to have_content('project-issue-bar') - expect(page).to have_content('project-issue-foo') - expect(page).not_to have_content('group-issue-bar') - expect(page).not_to have_content('group-issue-foo') + expect(page).to have_content(:all, 'project-issue-bar') + expect(page).to have_content(:all, 'project-issue-foo') + expect(page).not_to have_content(:all, 'group-issue-bar') + expect(page).not_to have_content(:all, 'group-issue-foo') end end end diff --git a/spec/support/shared_examples/services/alert_management/alert_processing/alert_firing_shared_examples.rb b/spec/support/shared_examples/services/alert_management/alert_processing/alert_firing_shared_examples.rb index 92a7d7ab3a3..ca86cb082a7 100644 --- a/spec/support/shared_examples/services/alert_management/alert_processing/alert_firing_shared_examples.rb +++ b/spec/support/shared_examples/services/alert_management/alert_processing/alert_firing_shared_examples.rb @@ -3,7 +3,10 @@ # This shared_example requires the following variables: # - `service`, the service which includes AlertManagement::AlertProcessing RSpec.shared_examples 'creates an alert management alert or errors' do - it { is_expected.to be_success } + specify do + expect(subject).to be_success + expect(subject.payload).to match(alerts: all(a_kind_of(AlertManagement::Alert))) + end it 'creates AlertManagement::Alert' do expect(Gitlab::AppLogger).not_to receive(:warn) @@ -89,6 +92,7 @@ RSpec.shared_examples 'adds an alert management alert event' do expect { subject }.to change { alert.reload.events }.by(1) expect(subject).to be_success + expect(subject.payload).to match(alerts: all(a_kind_of(AlertManagement::Alert))) end it_behaves_like 'does not create an alert management alert' diff --git a/spec/support/shared_examples/services/jira/requests/base_shared_examples.rb b/spec/support/shared_examples/services/jira/requests/base_shared_examples.rb index 56a6d24d557..c4f6273b46c 100644 --- a/spec/support/shared_examples/services/jira/requests/base_shared_examples.rb +++ b/spec/support/shared_examples/services/jira/requests/base_shared_examples.rb @@ -26,11 +26,14 @@ RSpec.shared_examples 'a service that handles Jira API errors' do expect(subject).to be_a(ServiceResponse) expect(subject).to be_error - expect(subject.message).to include(expected_message) + expect(subject.message).to start_with(expected_message) end end context 'when the JSON in JIRA::HTTPError is unsafe' do + config_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/configure') + let(:docs_link_start) { ''.html_safe % { url: config_docs_link_url } } + before do stub_client_and_raise(JIRA::HTTPError, error) end @@ -39,7 +42,8 @@ RSpec.shared_examples 'a service that handles Jira API errors' do let(:error) { '{"errorMessages":' } it 'returns the default error message' do - expect(subject.message).to eq('An error occurred while requesting data from Jira. Check your Jira integration configuration and try again.') + error_message = 'An error occurred while requesting data from Jira. Check your %{docs_link_start}Jira integration configuration and try again.' % { docs_link_start: docs_link_start } + expect(subject.message).to eq(error_message) end end @@ -47,7 +51,8 @@ RSpec.shared_examples 'a service that handles Jira API errors' do let(:error) { '{"errorMessages":["foo"]}' } it 'sanitizes it' do - expect(subject.message).to eq('An error occurred while requesting data from Jira: foo. Check your Jira integration configuration and try again.') + error_message = 'An error occurred while requesting data from Jira: foo. Check your %{docs_link_start}Jira integration configuration and try again.' % { docs_link_start: docs_link_start } + expect(subject.message).to eq(error_message) end end end diff --git a/spec/support/shared_examples/services/resource_events/synthetic_notes_builder_shared_examples.rb b/spec/support/shared_examples/services/resource_events/synthetic_notes_builder_shared_examples.rb new file mode 100644 index 00000000000..716bee39fca --- /dev/null +++ b/spec/support/shared_examples/services/resource_events/synthetic_notes_builder_shared_examples.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'filters by paginated notes' do |event_type| + let(:event) { create(event_type) } # rubocop:disable Rails/SaveBang + + before do + create(event_type, issue: event.issue) + end + + it 'only returns given notes' do + paginated_notes = { event_type.to_s.pluralize => [double(id: event.id)] } + notes = described_class.new(event.issue, user, paginated_notes: paginated_notes).execute + + expect(notes.size).to eq(1) + expect(notes.first.event).to eq(event) + end + + context 'when paginated notes is empty' do + it 'does not return any notes' do + notes = described_class.new(event.issue, user, paginated_notes: {}).execute + + expect(notes.size).to eq(0) + end + end +end diff --git a/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb b/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb index 89c0841fbd6..e6da96e12ec 100644 --- a/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb +++ b/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb @@ -17,7 +17,7 @@ end RSpec.shared_examples 'returns in_progress based on Sidekiq::Status' do it 'returns true when job is enqueued' do - jid = described_class.perform_async + jid = described_class.with_status.perform_async expect(described_class.in_progress?(jid)).to eq(true) end diff --git a/spec/support/stub_snowplow.rb b/spec/support/stub_snowplow.rb index a21ce2399d7..c6e3b40972f 100644 --- a/spec/support/stub_snowplow.rb +++ b/spec/support/stub_snowplow.rb @@ -8,8 +8,6 @@ module StubSnowplow host = 'localhost' # rubocop:disable RSpec/AnyInstanceOf - allow_any_instance_of(Gitlab::Tracking::Destinations::ProductAnalytics).to receive(:event) - allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow) .to receive(:emitter) .and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size)) diff --git a/spec/support/test_reports/test_reports_helper.rb b/spec/support/test_reports/test_reports_helper.rb index 18b40a20cf1..85483062958 100644 --- a/spec/support/test_reports/test_reports_helper.rb +++ b/spec/support/test_reports/test_reports_helper.rb @@ -95,9 +95,9 @@ module TestReportsHelper <<-EOF.strip_heredoc junit.framework.AssertionFailedError: expected:<1> but was:<3> at CalculatorTest.subtractExpression(Unknown Source) - at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) - at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/jdk.internal.database.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.database.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) + at java.base/jdk.internal.database.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) EOF end end diff --git a/spec/support/time_travel.rb b/spec/support/time_travel.rb new file mode 100644 index 00000000000..9dfbfd20524 --- /dev/null +++ b/spec/support/time_travel.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'active_support/testing/time_helpers' + +RSpec.configure do |config| + config.include ActiveSupport::Testing::TimeHelpers + + config.around(:example, :freeze_time) do |example| + freeze_time { example.run } + end + + config.around(:example, :time_travel_to) do |example| + date_or_time = example.metadata[:time_travel_to] + + unless date_or_time.respond_to?(:to_time) && date_or_time.to_time.present? + raise 'The time_travel_to RSpec metadata must have a Date or Time value.' + end + + travel_to(date_or_time) { example.run } + end +end diff --git a/spec/support_specs/database/multiple_databases_spec.rb b/spec/support_specs/database/multiple_databases_spec.rb index 6ad15fd6594..10d1a8277c6 100644 --- a/spec/support_specs/database/multiple_databases_spec.rb +++ b/spec/support_specs/database/multiple_databases_spec.rb @@ -19,19 +19,19 @@ RSpec.describe 'Database::MultipleDatabases' do end end - context 'on Ci::CiDatabaseRecord' do + context 'on Ci::ApplicationRecord' do before do skip_if_multiple_databases_not_setup end it 'raises exception' do - expect { Ci::CiDatabaseRecord.establish_connection(:ci) }.to raise_error /Cannot re-establish/ + expect { Ci::ApplicationRecord.establish_connection(:ci) }.to raise_error /Cannot re-establish/ end context 'when using with_reestablished_active_record_base' do it 'does not raise exception' do with_reestablished_active_record_base do - expect { Ci::CiDatabaseRecord.establish_connection(:main) }.not_to raise_error + expect { Ci::ApplicationRecord.establish_connection(:main) }.not_to raise_error end end end diff --git a/spec/support_specs/database/prevent_cross_database_modification_spec.rb b/spec/support_specs/database/prevent_cross_database_modification_spec.rb deleted file mode 100644 index e86559bb14a..00000000000 --- a/spec/support_specs/database/prevent_cross_database_modification_spec.rb +++ /dev/null @@ -1,163 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Database::PreventCrossDatabaseModification' do - let_it_be(:pipeline, refind: true) { create(:ci_pipeline) } - let_it_be(:project, refind: true) { create(:project) } - - shared_examples 'succeessful examples' do - context 'outside transaction' do - it { expect { run_queries }.not_to raise_error } - end - - context 'within transaction' do - it do - Project.transaction do - expect { run_queries }.not_to raise_error - end - end - end - - context 'within nested transaction' do - it do - Project.transaction(requires_new: true) do - Project.transaction(requires_new: true) do - expect { run_queries }.not_to raise_error - end - end - end - end - end - - context 'when CI and other tables are read in a transaction' do - def run_queries - pipeline.reload - project.reload - end - - include_examples 'succeessful examples' - end - - context 'when only CI data is modified' do - def run_queries - pipeline.touch - project.reload - end - - include_examples 'succeessful examples' - end - - context 'when other data is modified' do - def run_queries - pipeline.reload - project.touch - end - - include_examples 'succeessful examples' - end - - describe 'with_cross_database_modification_prevented block' do - it 'raises error when CI and other data is modified' do - expect do - with_cross_database_modification_prevented do - Project.transaction do - project.touch - pipeline.touch - end - end - end.to raise_error /Cross-database data modification/ - end - end - - context 'when running tests with prevent_cross_database_modification', :prevent_cross_database_modification do - context 'when both CI and other data is modified' do - def run_queries - project.touch - pipeline.touch - end - - context 'outside transaction' do - it { expect { run_queries }.not_to raise_error } - end - - context 'when data modification happens in a transaction' do - it 'raises error' do - Project.transaction do - expect { run_queries }.to raise_error /Cross-database data modification/ - end - end - - context 'when data modification happens in nested transactions' do - it 'raises error' do - Project.transaction(requires_new: true) do - project.touch - Project.transaction(requires_new: true) do - expect { pipeline.touch }.to raise_error /Cross-database data modification/ - end - end - end - end - end - - context 'when executing a SELECT FOR UPDATE query' do - def run_queries - project.touch - pipeline.lock! - end - - context 'outside transaction' do - it { expect { run_queries }.not_to raise_error } - end - - context 'when data modification happens in a transaction' do - it 'raises error' do - Project.transaction do - expect { run_queries }.to raise_error /Cross-database data modification/ - end - end - end - end - end - - context 'when CI association is modified through project' do - def run_queries - project.variables.build(key: 'a', value: 'v') - project.save! - end - - include_examples 'succeessful examples' - end - - describe '#allow_cross_database_modification_within_transaction' do - it 'skips raising error' do - expect do - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'gitlab-issue') do - Project.transaction do - pipeline.touch - project.touch - end - end - end.not_to raise_error - end - - it 'raises error when complex factories are built referencing both databases' do - expect do - ApplicationRecord.transaction do - create(:ci_pipeline) - end - end.to raise_error /Cross-database data modification/ - end - - it 'skips raising error on factory creation' do - expect do - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'gitlab-issue') do - ApplicationRecord.transaction do - create(:ci_pipeline) - end - end - end.not_to raise_error - end - end - end -end diff --git a/spec/support_specs/helpers/stub_feature_flags_spec.rb b/spec/support_specs/helpers/stub_feature_flags_spec.rb index 8629e895fd1..9b35fe35259 100644 --- a/spec/support_specs/helpers/stub_feature_flags_spec.rb +++ b/spec/support_specs/helpers/stub_feature_flags_spec.rb @@ -97,7 +97,7 @@ RSpec.describe StubFeatureFlags do context 'type handling' do context 'raises error' do where(:feature_actors) do - ['string', 1, 1.0, OpenStruct.new] + ['string', 1, 1.0, Object.new] end with_them do diff --git a/spec/support_specs/time_travel_spec.rb b/spec/support_specs/time_travel_spec.rb new file mode 100644 index 00000000000..8fa51c0c1f0 --- /dev/null +++ b/spec/support_specs/time_travel_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'time travel' do + describe ':freeze_time' do + it 'freezes time around a spec example', :freeze_time do + expect { sleep 0.1 }.not_to change { Time.now.to_f } + end + end + + describe ':time_travel_to' do + it 'time-travels to the specified date', time_travel_to: '2020-01-01' do + expect(Date.current).to eq(Date.new(2020, 1, 1)) + end + + it 'time-travels to the specified date & time', time_travel_to: '2020-02-02 10:30:45 -0700' do + expect(Time.current).to eq(Time.new(2020, 2, 2, 17, 30, 45, '+00:00')) + end + end +end diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb index ad4ada9a9f1..38392f77307 100644 --- a/spec/tasks/gitlab/db_rake_spec.rb +++ b/spec/tasks/gitlab/db_rake_spec.rb @@ -201,9 +201,11 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do describe 'reindex' do let(:reindex) { double('reindex') } let(:indexes) { double('indexes') } + let(:databases) { Gitlab::Database.database_base_models } + let(:databases_count) { databases.count } it 'cleans up any leftover indexes' do - expect(Gitlab::Database::Reindexing).to receive(:cleanup_leftovers!) + expect(Gitlab::Database::Reindexing).to receive(:cleanup_leftovers!).exactly(databases_count).times run_rake_task('gitlab:db:reindex') end @@ -212,8 +214,8 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do it 'executes async index creation prior to any reindexing actions' do stub_feature_flags(database_async_index_creation: true) - expect(Gitlab::Database::AsyncIndexes).to receive(:create_pending_indexes!).ordered - expect(Gitlab::Database::Reindexing).to receive(:perform).ordered + expect(Gitlab::Database::AsyncIndexes).to receive(:create_pending_indexes!).ordered.exactly(databases_count).times + expect(Gitlab::Database::Reindexing).to receive(:automatic_reindexing).ordered.exactly(databases_count).times run_rake_task('gitlab:db:reindex') end @@ -229,38 +231,30 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do end end - context 'when no index_name is given' do + context 'calls automatic reindexing' do it 'uses all candidate indexes' do - expect(Gitlab::Database::PostgresIndex).to receive(:reindexing_support).and_return(indexes) - expect(Gitlab::Database::Reindexing).to receive(:perform).with(indexes) + expect(Gitlab::Database::Reindexing).to receive(:automatic_reindexing).exactly(databases_count).times run_rake_task('gitlab:db:reindex') end end + end - context 'with index name given' do - let(:index) { double('index') } - - before do - allow(Gitlab::Database::PostgresIndex).to receive(:reindexing_support).and_return(indexes) - end - - it 'calls the index rebuilder with the proper arguments' do - allow(indexes).to receive(:where).with(identifier: 'public.foo_idx').and_return([index]) - expect(Gitlab::Database::Reindexing).to receive(:perform).with([index]) - - run_rake_task('gitlab:db:reindex', '[public.foo_idx]') - end + describe 'enqueue_reindexing_action' do + let(:index_name) { 'public.users_pkey' } - it 'raises an error if the index does not exist' do - allow(indexes).to receive(:where).with(identifier: 'public.absent_index').and_return([]) + it 'creates an entry in the queue' do + expect do + run_rake_task('gitlab:db:enqueue_reindexing_action', "[#{index_name}, main]") + end.to change { Gitlab::Database::PostgresIndex.find(index_name).queued_reindexing_actions.size }.from(0).to(1) + end - expect { run_rake_task('gitlab:db:reindex', '[public.absent_index]') }.to raise_error(/Index not found/) - end + it 'defaults to main database' do + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(ActiveRecord::Base.connection).and_call_original - it 'raises an error if the index is not fully qualified with a schema' do - expect { run_rake_task('gitlab:db:reindex', '[foo_idx]') }.to raise_error(/Index name is not fully qualified/) - end + expect do + run_rake_task('gitlab:db:enqueue_reindexing_action', "[#{index_name}]") + end.to change { Gitlab::Database::PostgresIndex.find(index_name).queued_reindexing_actions.size }.from(0).to(1) end end diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb index 5adea832995..c5625db922d 100644 --- a/spec/tasks/gitlab/gitaly_rake_spec.rb +++ b/spec/tasks/gitlab/gitaly_rake_spec.rb @@ -67,34 +67,57 @@ RSpec.describe 'gitlab:gitaly namespace rake task', :silence_stdout do end it 'calls gmake in the gitaly directory' do - expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['/usr/bin/gmake', 0]) - expect(Gitlab::Popen).to receive(:popen).with(%w[gmake], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil }).and_return(true) + expect(Gitlab::Popen).to receive(:popen) + .with(%w[which gmake]) + .and_return(['/usr/bin/gmake', 0]) + expect(Gitlab::Popen).to receive(:popen) + .with(%w[gmake all git], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil }) + .and_return(['ok', 0]) subject end + + context 'when gmake fails' do + it 'aborts process' do + expect(Gitlab::Popen).to receive(:popen) + .with(%w[which gmake]) + .and_return(['/usr/bin/gmake', 0]) + expect(Gitlab::Popen).to receive(:popen) + .with(%w[gmake all git], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil }) + .and_return(['output', 1]) + + expect { subject }.to raise_error /Gitaly failed to compile: output/ + end + end end context 'gmake is not available' do before do expect(main_object).to receive(:checkout_or_clone_version) - expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['', 42]) + expect(Gitlab::Popen).to receive(:popen) + .with(%w[which gmake]) + .and_return(['', 42]) end it 'calls make in the gitaly directory' do - expect(Gitlab::Popen).to receive(:popen).with(%w[make], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil }).and_return(true) + expect(Gitlab::Popen).to receive(:popen) + .with(%w[make all git], nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil }) + .and_return(['output', 0]) subject end context 'when Rails.env is test' do - let(:command) { %w[make] } + let(:command) { %w[make all git] } before do stub_rails_env('test') end it 'calls make in the gitaly directory with BUNDLE_DEPLOYMENT and GEM_HOME variables' do - expect(Gitlab::Popen).to receive(:popen).with(command, nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil, "BUNDLE_DEPLOYMENT" => 'false', "GEM_HOME" => Bundler.bundle_path.to_s }).and_return(true) + expect(Gitlab::Popen).to receive(:popen) + .with(command, nil, { "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil, "BUNDLE_DEPLOYMENT" => 'false', "GEM_HOME" => Bundler.bundle_path.to_s }) + .and_return(['/usr/bin/gmake', 0]) subject end diff --git a/spec/tasks/gitlab/storage_rake_spec.rb b/spec/tasks/gitlab/storage_rake_spec.rb index 570f67c8bb7..38a031178ae 100644 --- a/spec/tasks/gitlab/storage_rake_spec.rb +++ b/spec/tasks/gitlab/storage_rake_spec.rb @@ -90,7 +90,7 @@ RSpec.describe 'rake gitlab:storage:*', :silence_stdout do shared_examples 'wait until database is ready' do it 'checks if the database is ready once' do - expect(Gitlab::Database.main).to receive(:exists?).once + expect(ApplicationRecord.database).to receive(:exists?).once run_rake_task(task) end @@ -102,7 +102,7 @@ RSpec.describe 'rake gitlab:storage:*', :silence_stdout do end it 'tries for 3 times, polling every 0.1 seconds' do - expect(Gitlab::Database.main).to receive(:exists?).exactly(3).times.and_return(false) + expect(ApplicationRecord.database).to receive(:exists?).exactly(3).times.and_return(false) run_rake_task(task) end diff --git a/spec/tooling/danger/changelog_spec.rb b/spec/tooling/danger/changelog_spec.rb index 5777186cc28..377c3e881c9 100644 --- a/spec/tooling/danger/changelog_spec.rb +++ b/spec/tooling/danger/changelog_spec.rb @@ -228,7 +228,7 @@ RSpec.describe Tooling::Danger::Changelog do end context 'with changelog label' do - let(:mr_labels) { ['feature'] } + let(:mr_labels) { ['type::feature'] } it 'is truthy' do is_expected.to be_truthy @@ -236,7 +236,7 @@ RSpec.describe Tooling::Danger::Changelog do end context 'with no changelog label' do - let(:mr_labels) { ['tooling'] } + let(:mr_labels) { ['type::tooling'] } it 'is truthy' do is_expected.to be_falsey diff --git a/spec/tooling/danger/product_intelligence_spec.rb b/spec/tooling/danger/product_intelligence_spec.rb index 5fd44ef5de0..c090dbb4de4 100644 --- a/spec/tooling/danger/product_intelligence_spec.rb +++ b/spec/tooling/danger/product_intelligence_spec.rb @@ -44,20 +44,26 @@ RSpec.describe Tooling::Danger::ProductIntelligence do context 'with product intelligence label' do let(:expected_labels) { ['product intelligence::review pending'] } + let(:mr_labels) { [] } before do allow(fake_helper).to receive(:mr_has_labels?).with('product intelligence').and_return(true) + allow(fake_helper).to receive(:mr_labels).and_return(mr_labels) end it { is_expected.to match_array(expected_labels) } - end - context 'with product intelligence::review pending' do - before do - allow(fake_helper).to receive(:mr_has_labels?).and_return(true) + context 'with product intelligence::review pending' do + let(:mr_labels) { ['product intelligence::review pending'] } + + it { is_expected.to be_empty } end - it { is_expected.to be_empty } + context 'with product intelligence::approved' do + let(:mr_labels) { ['product intelligence::approved'] } + + it { is_expected.to be_empty } + end end context 'with growth experiment label' do @@ -68,71 +74,4 @@ RSpec.describe Tooling::Danger::ProductIntelligence do it { is_expected.to be_empty } end end - - describe '#matching_changed_files' do - subject { product_intelligence.matching_changed_files } - - let(:changed_files) do - [ - 'dashboard/todos_controller.rb', - 'components/welcome.vue', - 'admin/groups/_form.html.haml' - ] - end - - context 'with snowplow files changed' do - context 'when vue file changed' do - let(:changed_lines) { ['+data-track-action'] } - - it { is_expected.to match_array(['components/welcome.vue']) } - end - - context 'when haml file changed' do - let(:changed_lines) { ['+ data: { track_label:'] } - - it { is_expected.to match_array(['admin/groups/_form.html.haml']) } - end - - context 'when ruby file changed' do - let(:changed_lines) { ['+ Gitlab::Tracking.event'] } - let(:changed_files) { ['dashboard/todos_controller.rb', 'admin/groups/_form.html.haml'] } - - it { is_expected.to match_array(['dashboard/todos_controller.rb']) } - end - end - - context 'with metrics files changed' do - let(:changed_files) { ['config/metrics/counts_7d/test_metric.yml', 'ee/config/metrics/counts_7d/ee_metric.yml'] } - - it { is_expected.to match_array(changed_files) } - end - - context 'with metrics files not changed' do - it { is_expected.to be_empty } - end - - context 'with tracking files changed' do - let(:changed_files) do - [ - 'lib/gitlab/tracking.rb', - 'spec/lib/gitlab/tracking_spec.rb', - 'app/helpers/tracking_helper.rb' - ] - end - - it { is_expected.to match_array(changed_files) } - end - - context 'with usage_data files changed' do - let(:changed_files) do - [ - 'doc/api/usage_data.md', - 'ee/lib/ee/gitlab/usage_data.rb', - 'spec/lib/gitlab/usage_data_spec.rb' - ] - end - - it { is_expected.to match_array(changed_files) } - end - end end diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb index 5edd9e54cc5..ec475df6d83 100644 --- a/spec/tooling/danger/project_helper_spec.rb +++ b/spec/tooling/danger/project_helper_spec.rb @@ -7,8 +7,10 @@ require 'danger/plugins/helper' require 'gitlab/dangerfiles/spec_helper' require_relative '../../../danger/plugins/project_helper' +require_relative '../../../spec/support/helpers/stub_env' RSpec.describe Tooling::Danger::ProjectHelper do + include StubENV include_context "with dangerfile" let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) } @@ -40,7 +42,7 @@ RSpec.describe Tooling::Danger::ProjectHelper do using RSpec::Parameterized::TableSyntax before do - allow(fake_git).to receive(:diff_for_file).with('usage_data.rb') { double(:diff, patch: "+ count(User.active)") } + allow(fake_git).to receive(:diff_for_file).with(instance_of(String)) { double(:diff, patch: "+ count(User.active)") } end where(:path, :expected_categories) do @@ -189,6 +191,58 @@ RSpec.describe Tooling::Danger::ProjectHelper do 'spec/frontend/tracking/foo.js' | [:frontend, :product_intelligence] 'spec/frontend/tracking_spec.js' | [:frontend, :product_intelligence] 'lib/gitlab/usage_database/foo.rb' | [:backend] + 'config/metrics/counts_7d/test_metric.yml' | [:product_intelligence] + 'config/metrics/schema.json' | [:product_intelligence] + 'doc/api/usage_data.md' | [:product_intelligence] + 'spec/lib/gitlab/usage_data_spec.rb' | [:product_intelligence] + + 'app/models/integration.rb' | [:integrations_be, :backend] + 'ee/app/models/integrations/github.rb' | [:integrations_be, :backend] + 'ee/app/models/ee/integrations/jira.rb' | [:integrations_be, :backend] + 'app/models/integrations/chat_message/pipeline_message.rb' | [:integrations_be, :backend] + 'app/models/jira_connect_subscription.rb' | [:integrations_be, :backend] + 'app/models/hooks/service_hook.rb' | [:integrations_be, :backend] + 'ee/app/models/ee/hooks/system_hook.rb' | [:integrations_be, :backend] + 'app/services/concerns/integrations/project_test_data.rb' | [:integrations_be, :backend] + 'ee/app/services/ee/integrations/test/project_service.rb' | [:integrations_be, :backend] + 'app/controllers/concerns/integrations/actions.rb' | [:integrations_be, :backend] + 'ee/app/controllers/concerns/ee/integrations/params.rb' | [:integrations_be, :backend] + 'ee/app/controllers/projects/integrations/jira/issues_controller.rb' | [:integrations_be, :backend] + 'app/controllers/projects/hooks_controller.rb' | [:integrations_be, :backend] + 'app/controllers/admin/hook_logs_controller.rb' | [:integrations_be, :backend] + 'app/controllers/groups/settings/integrations_controller.rb' | [:integrations_be, :backend] + 'app/controllers/jira_connect/branches_controller.rb' | [:integrations_be, :backend] + 'app/controllers/oauth/jira/authorizations_controller.rb' | [:integrations_be, :backend] + 'ee/app/finders/projects/integrations/jira/by_ids_finder.rb' | [:integrations_be, :database, :backend] + 'app/workers/jira_connect/sync_merge_request_worker.rb' | [:integrations_be, :backend] + 'app/workers/propagate_integration_inherit_worker.rb' | [:integrations_be, :backend] + 'app/workers/web_hooks/log_execution_worker.rb' | [:integrations_be, :backend] + 'app/workers/web_hook_worker.rb' | [:integrations_be, :backend] + 'app/workers/project_service_worker.rb' | [:integrations_be, :backend] + 'lib/atlassian/jira_connect/serializers/commit_entity.rb' | [:integrations_be, :backend] + 'lib/api/entities/project_integration.rb' | [:integrations_be, :backend] + 'lib/gitlab/hook_data/note_builder.rb' | [:integrations_be, :backend] + 'lib/gitlab/data_builder/note.rb' | [:integrations_be, :backend] + 'ee/lib/ee/gitlab/integrations/sti_type.rb' | [:integrations_be, :backend] + 'ee/lib/ee/api/helpers/integrations_helpers.rb' | [:integrations_be, :backend] + 'ee/app/serializers/integrations/jira_serializers/issue_entity.rb' | [:integrations_be, :backend] + 'lib/api/github/entities.rb' | [:integrations_be, :backend] + 'lib/api/v3/github.rb' | [:integrations_be, :backend] + 'app/models/clusters/integrations/elastic_stack.rb' | [:backend] + 'app/controllers/clusters/integrations_controller.rb' | [:backend] + 'app/services/clusters/integrations/prometheus_health_check_service.rb' | [:backend] + 'app/graphql/types/alert_management/integration_type.rb' | [:backend] + + 'app/views/jira_connect/branches/new.html.haml' | [:integrations_fe, :frontend] + 'app/views/layouts/jira_connect.html.haml' | [:integrations_fe, :frontend] + 'app/assets/javascripts/jira_connect/branches/pages/index.vue' | [:integrations_fe, :frontend] + 'ee/app/views/projects/integrations/jira/issues/show.html.haml' | [:integrations_fe, :frontend] + 'ee/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql' | [:integrations_fe, :frontend] + 'app/assets/javascripts/pages/projects/settings/integrations/show/index.js' | [:integrations_fe, :frontend] + 'ee/app/assets/javascripts/pages/groups/hooks/index.js' | [:integrations_fe, :frontend] + 'app/views/clusters/clusters/_integrations_tab.html.haml' | [:frontend] + 'app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql' | [:frontend] + 'app/assets/javascripts/filtered_search/droplab/hook_input.js' | [:frontend] end with_them do @@ -199,12 +253,20 @@ RSpec.describe Tooling::Danger::ProjectHelper do context 'having specific changes' do where(:expected_categories, :patch, :changed_files) do + [:product_intelligence] | '+data-track-action' | ['components/welcome.vue'] + [:product_intelligence] | '+ data: { track_label:' | ['admin/groups/_form.html.haml'] + [:product_intelligence] | '+ Gitlab::Tracking.event' | ['dashboard/todos_controller.rb', 'admin/groups/_form.html.haml'] [:database, :backend, :product_intelligence] | '+ count(User.active)' | ['usage_data.rb', 'lib/gitlab/usage_data.rb', 'ee/lib/ee/gitlab/usage_data.rb'] [:database, :backend, :product_intelligence] | '+ estimate_batch_distinct_count(User.active)' | ['usage_data.rb'] [:backend, :product_intelligence] | '+ alt_usage_data(User.active)' | ['lib/gitlab/usage_data.rb'] [:backend, :product_intelligence] | '+ count(User.active)' | ['lib/gitlab/usage_data/topology.rb'] [:backend, :product_intelligence] | '+ foo_count(User.active)' | ['lib/gitlab/usage_data.rb'] [:backend] | '+ count(User.active)' | ['user.rb'] + [:integrations_be, :database, :migration] | '+ add_column :integrations, :foo, :text' | ['db/migrate/foo.rb'] + [:integrations_be, :database, :migration] | '+ create_table :zentao_tracker_data do |t|' | ['ee/db/post_migrate/foo.rb'] + [:integrations_be, :backend] | '+ Integrations::Foo' | ['app/foo/bar.rb'] + [:integrations_be, :backend] | '+ project.execute_hooks(foo, :bar)' | ['ee/lib/ee/foo.rb'] + [:integrations_be, :backend] | '+ project.execute_integrations(foo, :bar)' | ['app/foo.rb'] end with_them do @@ -281,6 +343,70 @@ RSpec.describe Tooling::Danger::ProjectHelper do end end + describe '#ee?' do + subject { project_helper.__send__(:ee?) } + + let(:ee_dir) { File.expand_path('../../../ee', __dir__) } + + context 'when ENV["CI_PROJECT_NAME"] is set' do + before do + stub_env('CI_PROJECT_NAME', ci_project_name) + end + + context 'when ENV["CI_PROJECT_NAME"] is gitlab' do + let(:ci_project_name) { 'gitlab' } + + it 'returns true' do + is_expected.to eq(true) + end + end + + context 'when ENV["CI_PROJECT_NAME"] is gitlab-ee' do + let(:ci_project_name) { 'gitlab-ee' } + + it 'returns true' do + is_expected.to eq(true) + end + end + + context 'when ENV["CI_PROJECT_NAME"] is gitlab-foss' do + let(:ci_project_name) { 'gitlab-foss' } + + it 'resolves to Dir.exist?' do + expected = Dir.exist?(ee_dir) + + expect(Dir).to receive(:exist?).with(ee_dir).and_call_original + + is_expected.to eq(expected) + end + end + end + + context 'when ENV["CI_PROJECT_NAME"] is absent' do + before do + stub_env('CI_PROJECT_NAME', nil) + + expect(Dir).to receive(:exist?).with(ee_dir).and_return(has_ee_dir) + end + + context 'when ee/ directory exists' do + let(:has_ee_dir) { true } + + it 'returns true' do + is_expected.to eq(true) + end + end + + context 'when ee/ directory does not exist' do + let(:has_ee_dir) { false } + + it 'returns false' do + is_expected.to eq(false) + end + end + end + end + describe '#file_lines' do let(:filename) { 'spec/foo_spec.rb' } let(:file_spy) { spy } diff --git a/spec/tooling/quality/test_level_spec.rb b/spec/tooling/quality/test_level_spec.rb index 0623a67a60e..94fa9d682e1 100644 --- a/spec/tooling/quality/test_level_spec.rb +++ b/spec/tooling/quality/test_level_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Quality::TestLevel do context 'when level is unit' do it 'returns a pattern' do expect(subject.pattern(:unit)) - .to eq("spec/{bin,channels,config,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb") + .to eq("spec/{bin,channels,config,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,scripts,serializers,services,sidekiq,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb") end end @@ -49,7 +49,7 @@ RSpec.describe Quality::TestLevel do context 'when level is integration' do it 'returns a pattern' do expect(subject.pattern(:integration)) - .to eq("spec/{controllers,mailers,requests}{,/**/}*_spec.rb") + .to eq("spec/{commands,controllers,mailers,requests}{,/**/}*_spec.rb") end end @@ -110,7 +110,7 @@ RSpec.describe Quality::TestLevel do context 'when level is unit' do it 'returns a regexp' do expect(subject.regexp(:unit)) - .to eq(%r{spec/(bin|channels|config|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)}) + .to eq(%r{spec/(bin|channels|config|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|scripts|serializers|services|sidekiq|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)}) end end @@ -131,7 +131,7 @@ RSpec.describe Quality::TestLevel do context 'when level is integration' do it 'returns a regexp' do expect(subject.regexp(:integration)) - .to eq(%r{spec/(controllers|mailers|requests)}) + .to eq(%r{spec/(commands|controllers|mailers|requests)}) end end @@ -204,6 +204,10 @@ RSpec.describe Quality::TestLevel do expect(subject.level_for('spec/mailers/abuse_report_mailer_spec.rb')).to eq(:integration) end + it 'returns the correct level for an integration test in a subfolder' do + expect(subject.level_for('spec/commands/sidekiq_cluster/cli.rb')).to eq(:integration) + end + it 'returns the correct level for a system test' do expect(subject.level_for('spec/features/abuse_report_spec.rb')).to eq(:system) end diff --git a/spec/validators/addressable_url_validator_spec.rb b/spec/validators/addressable_url_validator_spec.rb index ec3ee9aa500..7e2cc2afa8a 100644 --- a/spec/validators/addressable_url_validator_spec.rb +++ b/spec/validators/addressable_url_validator_spec.rb @@ -14,6 +14,7 @@ RSpec.describe AddressableUrlValidator do describe 'validations' do include_context 'invalid urls' + include_context 'valid urls with CRLF' let(:validator) { described_class.new(attributes: [:link_url]) } @@ -27,9 +28,20 @@ RSpec.describe AddressableUrlValidator do expect(badge.errors.added?(:link_url, validator.options.fetch(:message))).to be true end + it 'allows urls with encoded CR or LF characters' do + aggregate_failures do + valid_urls_with_CRLF.each do |url| + validator.validate_each(badge, :link_url, url) + + expect(badge.errors).to be_empty + end + end + end + it 'does not allow urls with CR or LF characters' do aggregate_failures do urls_with_CRLF.each do |url| + badge = build(:badge, link_url: 'http://www.example.com') validator.validate_each(badge, :link_url, url) expect(badge.errors.added?(:link_url, 'is blocked: URI is invalid')).to be true diff --git a/spec/views/groups/settings/_remove.html.haml_spec.rb b/spec/views/groups/settings/_remove.html.haml_spec.rb index 07fe900bc2d..e40fda58a72 100644 --- a/spec/views/groups/settings/_remove.html.haml_spec.rb +++ b/spec/views/groups/settings/_remove.html.haml_spec.rb @@ -9,8 +9,8 @@ RSpec.describe 'groups/settings/_remove.html.haml' do render 'groups/settings/remove', group: group - expect(rendered).to have_selector '[data-testid="remove-group-button"]' - expect(rendered).not_to have_selector '[data-testid="remove-group-button"].disabled' + expect(rendered).to have_selector '[data-button-testid="remove-group-button"]' + expect(rendered).not_to have_selector '[data-button-testid="remove-group-button"].disabled' expect(rendered).not_to have_selector '[data-testid="group-has-linked-subscription-alert"]' end end diff --git a/spec/views/groups/settings/_transfer.html.haml_spec.rb b/spec/views/groups/settings/_transfer.html.haml_spec.rb index b557c989eae..911eb5b7ab3 100644 --- a/spec/views/groups/settings/_transfer.html.haml_spec.rb +++ b/spec/views/groups/settings/_transfer.html.haml_spec.rb @@ -9,9 +9,9 @@ RSpec.describe 'groups/settings/_transfer.html.haml' do render 'groups/settings/transfer', group: group - expect(rendered).to have_selector '[data-qa-selector="select_group_dropdown"]' # rubocop:disable QA/SelectorUsage - expect(rendered).not_to have_selector '[data-qa-selector="select_group_dropdown"][disabled]' # rubocop:disable QA/SelectorUsage - expect(rendered).not_to have_selector '[data-testid="group-to-transfer-has-linked-subscription-alert"]' + expect(rendered).to have_button 'Select parent group' + expect(rendered).not_to have_button 'Select parent group', disabled: true + expect(rendered).not_to have_text "This group can't be transfered because it is linked to a subscription." end end end diff --git a/spec/views/jira_connect/subscriptions/index.html.haml_spec.rb b/spec/views/jira_connect/subscriptions/index.html.haml_spec.rb index dcc36c93327..0a4d283a983 100644 --- a/spec/views/jira_connect/subscriptions/index.html.haml_spec.rb +++ b/spec/views/jira_connect/subscriptions/index.html.haml_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'jira_connect/subscriptions/index.html.haml' do before do allow(view).to receive(:current_user).and_return(user) - assign(:subscriptions, []) + assign(:subscriptions, create_list(:jira_connect_subscription, 1)) end context 'when the user is signed in' do diff --git a/spec/views/layouts/_published_experiments.html.haml_spec.rb b/spec/views/layouts/_published_experiments.html.haml_spec.rb new file mode 100644 index 00000000000..d1ade8ddd6e --- /dev/null +++ b/spec/views/layouts/_published_experiments.html.haml_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'layouts/_published_experiments', :experiment do + before do + stub_const('TestControlExperiment', ApplicationExperiment) + stub_const('TestCandidateExperiment', ApplicationExperiment) + stub_const('TestExcludedExperiment', ApplicationExperiment) + + TestControlExperiment.new('test_control').tap do |e| + e.variant(:control) + e.publish + end + TestCandidateExperiment.new('test_candidate').tap do |e| + e.variant(:candidate) + e.publish + end + TestExcludedExperiment.new('test_excluded').tap do |e| + e.exclude! + e.publish + end + + render + end + + it 'renders out data for all non-excluded, published experiments' do + output = rendered + + expect(output).to include('gl.experiments = {') + expect(output).to match(/"test_control":\{[^}]*"variant":"control"/) + expect(output).to match(/"test_candidate":\{[^}]*"variant":"candidate"/) + expect(output).not_to include('"test_excluded"') + end +end diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index 20c5d9992be..f7da288b9f3 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -987,28 +987,10 @@ RSpec.describe 'layouts/nav/sidebar/_project' do end describe 'Usage Quotas' do - context 'with project_storage_ui feature flag enabled' do - before do - stub_feature_flags(project_storage_ui: true) - end - - it 'has a link to Usage Quotas' do - render - - expect(rendered).to have_link('Usage Quotas', href: project_usage_quotas_path(project)) - end - end - - context 'with project_storage_ui feature flag disabled' do - before do - stub_feature_flags(project_storage_ui: false) - end - - it 'does not have a link to Usage Quotas' do - render + it 'has a link to Usage Quotas' do + render - expect(rendered).not_to have_link('Usage Quotas', href: project_usage_quotas_path(project)) - end + expect(rendered).to have_link('Usage Quotas', href: project_usage_quotas_path(project)) end end end diff --git a/spec/views/profiles/audit_log.html.haml_spec.rb b/spec/views/profiles/audit_log.html.haml_spec.rb new file mode 100644 index 00000000000..d5f6a2d64e7 --- /dev/null +++ b/spec/views/profiles/audit_log.html.haml_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'profiles/audit_log' do + let(:user) { create(:user) } + + before do + assign(:user, user) + assign(:events, AuthenticationEvent.all.page(params[:page])) + allow(controller).to receive(:current_user).and_return(user) + end + + context 'when user has successful and failure events' do + before do + create(:authentication_event, :successful, user: user) + create(:authentication_event, :failed, user: user) + end + + it 'only shows successful events' do + render + + expect(rendered).to have_text('Signed in with standard authentication', count: 1) + end + end +end diff --git a/spec/views/projects/edit.html.haml_spec.rb b/spec/views/projects/edit.html.haml_spec.rb index b44d07d2ee4..60f4c1664f7 100644 --- a/spec/views/projects/edit.html.haml_spec.rb +++ b/spec/views/projects/edit.html.haml_spec.rb @@ -57,6 +57,41 @@ RSpec.describe 'projects/edit' do end end + context 'merge commit template' do + it 'displays all possible variables' do + render + + expect(rendered).to have_content('%{source_branch}') + expect(rendered).to have_content('%{target_branch}') + expect(rendered).to have_content('%{title}') + expect(rendered).to have_content('%{issues}') + expect(rendered).to have_content('%{description}') + expect(rendered).to have_content('%{reference}') + end + + it 'displays a placeholder if none is set' do + render + + expect(rendered).to have_field('project[merge_commit_template]', placeholder: <<~MSG.rstrip) + Merge branch '%{source_branch}' into '%{target_branch}' + + %{title} + + %{issues} + + See merge request %{reference} + MSG + end + + it 'displays the user entered value' do + project.update!(merge_commit_template: '%{title}') + + render + + expect(rendered).to have_field('project[merge_commit_template]', with: '%{title}') + end + end + context 'forking' do before do assign(:project, project) diff --git a/spec/views/projects/issues/_service_desk_info_content.html.haml_spec.rb b/spec/views/projects/issues/_service_desk_info_content.html.haml_spec.rb new file mode 100644 index 00000000000..1c6d729ddce --- /dev/null +++ b/spec/views/projects/issues/_service_desk_info_content.html.haml_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'projects/issues/_service_desk_info_content' do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:service_desk_address) { 'address@example.com' } + + before do + assign(:project, project) + allow(project).to receive(:service_desk_address).and_return(service_desk_address) + allow(view).to receive(:current_user).and_return(user) + end + + context 'when service desk is disabled' do + before do + allow(project).to receive(:service_desk_enabled?).and_return(false) + end + + context 'when the logged user is at least maintainer' do + before do + project.add_maintainer(user) + end + + it 'shows the info including the project settings link', :aggregate_failures do + render + + expect(rendered).to have_text('Use Service Desk') + expect(rendered).not_to have_text(service_desk_address) + expect(rendered).to have_link(href: "/#{project.full_path}/edit") + end + end + + context 'when the logged user is at only a developer' do + before do + project.add_developer(user) + end + + it 'shows the info without the project settings link', :aggregate_failures do + render + + expect(rendered).to have_text('Use Service Desk') + expect(rendered).not_to have_text(service_desk_address) + expect(rendered).not_to have_link(href: "/#{project.full_path}/edit") + end + end + end + + context 'when service desk is enabled' do + before do + allow(project).to receive(:service_desk_enabled?).and_return(true) + end + + context 'when the logged user is at least reporter' do + before do + project.add_reporter(user) + end + + it 'shows the info including the email address', :aggregate_failures do + render + + expect(rendered).to have_text('Use Service Desk') + expect(rendered).to have_text(service_desk_address) + expect(rendered).not_to have_link(href: "/#{project.full_path}/edit") + end + end + + context 'when the logged user is at only a guest' do + before do + project.add_guest(user) + end + + it 'shows the info without the email address', :aggregate_failures do + render + + expect(rendered).to have_text('Use Service Desk') + expect(rendered).not_to have_text(service_desk_address) + expect(rendered).not_to have_link(href: "/#{project.full_path}/edit") + end + end + + context 'when user is not logged in' do + let(:user) { nil } + + it 'shows the info without the email address', :aggregate_failures do + render + + expect(rendered).to have_text('Use Service Desk') + expect(rendered).not_to have_text(service_desk_address) + expect(rendered).not_to have_link(href: "/#{project.full_path}/edit") + end + end + end +end diff --git a/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb b/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb index dd180229d12..c45ec20fe5a 100644 --- a/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb +++ b/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb @@ -11,7 +11,8 @@ RSpec.describe Analytics::UsageTrends::CounterJobWorker do let(:job_args) { [users_measurement_identifier, user_1.id, user_2.id, recorded_at] } before do - allow(::Analytics::UsageTrends::Measurement.connection).to receive(:transaction_open?).and_return(false) + allow(::ApplicationRecord.connection).to receive(:transaction_open?).and_return(false) + allow(::Ci::ApplicationRecord.connection).to receive(:transaction_open?).and_return(false) if ::Ci::ApplicationRecord.connection_class? end include_examples 'an idempotent worker' do diff --git a/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb b/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb index f510852e753..fe4bc2421a4 100644 --- a/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb +++ b/spec/workers/ci/ref_delete_unlock_artifacts_worker_spec.rb @@ -4,7 +4,9 @@ require 'spec_helper' RSpec.describe Ci::RefDeleteUnlockArtifactsWorker do describe '#perform' do - subject(:perform) { described_class.new.perform(project_id, user_id, ref) } + subject(:perform) { worker.perform(project_id, user_id, ref) } + + let(:worker) { described_class.new } let(:ref) { 'refs/heads/master' } @@ -40,6 +42,36 @@ RSpec.describe Ci::RefDeleteUnlockArtifactsWorker do expect(service).to have_received(:execute).with(ci_ref) end + + context 'when a locked pipeline with persisted artifacts exists' do + let!(:pipeline) { create(:ci_pipeline, :with_persisted_artifacts, ref: 'master', project: project, locked: :artifacts_locked) } + + context 'with ci_update_unlocked_job_artifacts disabled' do + before do + stub_feature_flags(ci_update_unlocked_job_artifacts: false) + end + + it 'logs the correct extra metadata' do + expect(worker).to receive(:log_extra_metadata_on_done).with(:unlocked_pipelines, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:unlocked_job_artifacts, 0) + + perform + end + end + + context 'with ci_update_unlocked_job_artifacts enabled' do + before do + stub_feature_flags(ci_update_unlocked_job_artifacts: true) + end + + it 'logs the correct extra metadata' do + expect(worker).to receive(:log_extra_metadata_on_done).with(:unlocked_pipelines, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:unlocked_job_artifacts, 2) + + perform + end + end + end end context 'when ci ref does not exist for the given project' do diff --git a/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb b/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb index 650be1e84a9..be7f7ef5c8c 100644 --- a/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb +++ b/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb @@ -9,6 +9,10 @@ RSpec.describe Ci::ResourceGroups::AssignResourceFromResourceGroupWorker do expect(described_class.get_deduplicate_strategy).to eq(:until_executed) end + it 'has an option to reschedule once if deduplicated' do + expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once }) + end + describe '#perform' do subject { worker.perform(resource_group_id) } diff --git a/spec/workers/clusters/applications/check_prometheus_health_worker_spec.rb b/spec/workers/clusters/applications/check_prometheus_health_worker_spec.rb deleted file mode 100644 index fb779bf3b01..00000000000 --- a/spec/workers/clusters/applications/check_prometheus_health_worker_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::CheckPrometheusHealthWorker, '#perform' do - subject { described_class.new.perform } - - it 'triggers health service' do - cluster = create(:cluster) - allow(Gitlab::Monitor::DemoProjects).to receive(:primary_keys) - allow(Clusters::Cluster).to receive_message_chain(:with_application_prometheus, :with_project_http_integrations).and_return([cluster]) - - service_instance = instance_double(Clusters::Applications::PrometheusHealthCheckService) - expect(Clusters::Applications::PrometheusHealthCheckService).to receive(:new).with(cluster).and_return(service_instance) - expect(service_instance).to receive(:execute) - - subject - end -end diff --git a/spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb b/spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb new file mode 100644 index 00000000000..6f70870bd09 --- /dev/null +++ b/spec/workers/clusters/integrations/check_prometheus_health_worker_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Integrations::CheckPrometheusHealthWorker, '#perform' do + subject { described_class.new.perform } + + it 'triggers health service' do + cluster = create(:cluster) + allow(Gitlab::Monitor::DemoProjects).to receive(:primary_keys) + allow(Clusters::Cluster).to receive_message_chain(:with_integration_prometheus, :with_project_http_integrations).and_return([cluster]) + + service_instance = instance_double(Clusters::Integrations::PrometheusHealthCheckService) + expect(Clusters::Integrations::PrometheusHealthCheckService).to receive(:new).with(cluster).and_return(service_instance) + expect(service_instance).to receive(:execute) + + subject + end +end diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb index af038c81b9e..fbf39b3c7cd 100644 --- a/spec/workers/concerns/application_worker_spec.rb +++ b/spec/workers/concerns/application_worker_spec.rb @@ -285,48 +285,38 @@ RSpec.describe ApplicationWorker do end end - describe '.bulk_perform_async' do - before do - stub_const(worker.name, worker) + context 'different kinds of push_bulk' do + shared_context 'disable the `sidekiq_push_bulk_in_batches` feature flag' do + before do + stub_feature_flags(sidekiq_push_bulk_in_batches: false) + end end - it 'enqueues jobs in bulk' do - Sidekiq::Testing.fake! do - worker.bulk_perform_async([['Foo', [1]], ['Foo', [2]]]) - - expect(worker.jobs.count).to eq 2 - expect(worker.jobs).to all(include('enqueued_at')) + shared_context 'set safe limit beyond the number of jobs to be enqueued' do + before do + stub_const("#{described_class}::SAFE_PUSH_BULK_LIMIT", args.count + 1) end end - end - describe '.bulk_perform_in' do - before do - stub_const(worker.name, worker) + shared_context 'set safe limit below the number of jobs to be enqueued' do + before do + stub_const("#{described_class}::SAFE_PUSH_BULK_LIMIT", 2) + end end - context 'when delay is valid' do - it 'correctly schedules jobs' do - Sidekiq::Testing.fake! do - worker.bulk_perform_in(1.minute, [['Foo', [1]], ['Foo', [2]]]) + shared_examples_for 'returns job_id of all enqueued jobs' do + let(:job_id_regex) { /[0-9a-f]{12}/ } - expect(worker.jobs.count).to eq 2 - expect(worker.jobs).to all(include('at')) - end - end - end + it 'returns job_id of all enqueued jobs' do + job_ids = perform_action - context 'when delay is invalid' do - it 'raises an ArgumentError exception' do - expect { worker.bulk_perform_in(-60, [['Foo']]) } - .to raise_error(ArgumentError) + expect(job_ids.count).to eq(args.count) + expect(job_ids).to all(match(job_id_regex)) end end - context 'with batches' do - let(:batch_delay) { 1.minute } - - it 'correctly schedules jobs' do + shared_examples_for 'enqueues the jobs in a batched fashion, with each batch enqueing jobs as per the set safe limit' do + it 'enqueues the jobs in a batched fashion, with each batch enqueing jobs as per the set safe limit' do expect(Sidekiq::Client).to( receive(:push_bulk).with(hash_including('args' => [['Foo', [1]], ['Foo', [2]]])) .ordered @@ -337,29 +327,318 @@ RSpec.describe ApplicationWorker do .and_call_original) expect(Sidekiq::Client).to( receive(:push_bulk).with(hash_including('args' => [['Foo', [5]]])) - .ordered - .and_call_original) + .ordered + .and_call_original) - worker.bulk_perform_in( - 1.minute, - [['Foo', [1]], ['Foo', [2]], ['Foo', [3]], ['Foo', [4]], ['Foo', [5]]], - batch_size: 2, batch_delay: batch_delay) - - expect(worker.jobs.count).to eq 5 - expect(worker.jobs[0]['at']).to eq(worker.jobs[1]['at']) - expect(worker.jobs[2]['at']).to eq(worker.jobs[3]['at']) - expect(worker.jobs[2]['at'] - worker.jobs[1]['at']).to eq(batch_delay) - expect(worker.jobs[4]['at'] - worker.jobs[3]['at']).to eq(batch_delay) - end - - context 'when batch_size is invalid' do - it 'raises an ArgumentError exception' do - expect do - worker.bulk_perform_in(1.minute, - [['Foo']], - batch_size: -1, batch_delay: batch_delay) - end.to raise_error(ArgumentError) + perform_action + + expect(worker.jobs.count).to eq args.count + expect(worker.jobs).to all(include('enqueued_at')) + end + end + + shared_examples_for 'enqueues jobs in one go' do + it 'enqueues jobs in one go' do + expect(Sidekiq::Client).to( + receive(:push_bulk).with(hash_including('args' => args)).once.and_call_original) + expect(Sidekiq.logger).not_to receive(:info) + + perform_action + + expect(worker.jobs.count).to eq args.count + expect(worker.jobs).to all(include('enqueued_at')) + end + end + + shared_examples_for 'logs bulk insertions' do + it 'logs arguments and job IDs' do + worker.log_bulk_perform_async! + + expect(Sidekiq.logger).to( + receive(:info).with(hash_including('class' => worker.name, 'args_list' => args)).once.and_call_original) + expect(Sidekiq.logger).to( + receive(:info).with(hash_including('class' => worker.name, 'jid_list' => anything)).once.and_call_original) + + perform_action + end + end + + before do + stub_const(worker.name, worker) + end + + let(:args) do + [ + ['Foo', [1]], + ['Foo', [2]], + ['Foo', [3]], + ['Foo', [4]], + ['Foo', [5]] + ] + end + + describe '.bulk_perform_async' do + shared_examples_for 'does not schedule the jobs for any specific time' do + it 'does not schedule the jobs for any specific time' do + perform_action + + expect(worker.jobs).to all(exclude('at')) + end + end + + subject(:perform_action) do + worker.bulk_perform_async(args) + end + + context 'push_bulk in safe limit batches' do + context 'when the number of jobs to be enqueued does not exceed the safe limit' do + include_context 'set safe limit beyond the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'logs bulk insertions' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'does not schedule the jobs for any specific time' end + + context 'when the number of jobs to be enqueued exceeds safe limit' do + include_context 'set safe limit below the number of jobs to be enqueued' + + it_behaves_like 'enqueues the jobs in a batched fashion, with each batch enqueing jobs as per the set safe limit' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'does not schedule the jobs for any specific time' + end + + context 'when the feature flag `sidekiq_push_bulk_in_batches` is disabled' do + include_context 'disable the `sidekiq_push_bulk_in_batches` feature flag' + + context 'when the number of jobs to be enqueued does not exceed the safe limit' do + include_context 'set safe limit beyond the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'logs bulk insertions' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'does not schedule the jobs for any specific time' + end + + context 'when the number of jobs to be enqueued exceeds safe limit' do + include_context 'set safe limit below the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'does not schedule the jobs for any specific time' + end + end + end + end + + describe '.bulk_perform_in' do + context 'without batches' do + shared_examples_for 'schedules all the jobs at a specific time' do + it 'schedules all the jobs at a specific time' do + perform_action + + worker.jobs.each do |job_detail| + expect(job_detail['at']).to be_within(3.seconds).of(expected_scheduled_at_time) + end + end + end + + let(:delay) { 3.minutes } + let(:expected_scheduled_at_time) { Time.current.to_i + delay.to_i } + + subject(:perform_action) do + worker.bulk_perform_in(delay, args) + end + + context 'when the scheduled time falls in the past' do + let(:delay) { -60 } + + it 'raises an ArgumentError exception' do + expect { perform_action } + .to raise_error(ArgumentError) + end + end + + context 'push_bulk in safe limit batches' do + context 'when the number of jobs to be enqueued does not exceed the safe limit' do + include_context 'set safe limit beyond the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'schedules all the jobs at a specific time' + end + + context 'when the number of jobs to be enqueued exceeds safe limit' do + include_context 'set safe limit below the number of jobs to be enqueued' + + it_behaves_like 'enqueues the jobs in a batched fashion, with each batch enqueing jobs as per the set safe limit' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'schedules all the jobs at a specific time' + end + + context 'when the feature flag `sidekiq_push_bulk_in_batches` is disabled' do + include_context 'disable the `sidekiq_push_bulk_in_batches` feature flag' + + context 'when the number of jobs to be enqueued does not exceed the safe limit' do + include_context 'set safe limit beyond the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'schedules all the jobs at a specific time' + end + + context 'when the number of jobs to be enqueued exceeds safe limit' do + include_context 'set safe limit below the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'schedules all the jobs at a specific time' + end + end + end + end + + context 'with batches' do + shared_examples_for 'schedules all the jobs at a specific time, per batch' do + it 'schedules all the jobs at a specific time, per batch' do + perform_action + + expect(worker.jobs[0]['at']).to eq(worker.jobs[1]['at']) + expect(worker.jobs[2]['at']).to eq(worker.jobs[3]['at']) + expect(worker.jobs[2]['at'] - worker.jobs[1]['at']).to eq(batch_delay) + expect(worker.jobs[4]['at'] - worker.jobs[3]['at']).to eq(batch_delay) + end + end + + let(:delay) { 1.minute } + let(:batch_size) { 2 } + let(:batch_delay) { 10.minutes } + + subject(:perform_action) do + worker.bulk_perform_in(delay, args, batch_size: batch_size, batch_delay: batch_delay) + end + + context 'when the `batch_size` is invalid' do + context 'when `batch_size` is 0' do + let(:batch_size) { 0 } + + it 'raises an ArgumentError exception' do + expect { perform_action } + .to raise_error(ArgumentError) + end + end + + context 'when `batch_size` is negative' do + let(:batch_size) { -3 } + + it 'raises an ArgumentError exception' do + expect { perform_action } + .to raise_error(ArgumentError) + end + end + end + + context 'when the `batch_delay` is invalid' do + context 'when `batch_delay` is 0' do + let(:batch_delay) { 0.minutes } + + it 'raises an ArgumentError exception' do + expect { perform_action } + .to raise_error(ArgumentError) + end + end + + context 'when `batch_delay` is negative' do + let(:batch_delay) { -3.minutes } + + it 'raises an ArgumentError exception' do + expect { perform_action } + .to raise_error(ArgumentError) + end + end + end + + context 'push_bulk in safe limit batches' do + context 'when the number of jobs to be enqueued does not exceed the safe limit' do + include_context 'set safe limit beyond the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'schedules all the jobs at a specific time, per batch' + end + + context 'when the number of jobs to be enqueued exceeds safe limit' do + include_context 'set safe limit below the number of jobs to be enqueued' + + it_behaves_like 'enqueues the jobs in a batched fashion, with each batch enqueing jobs as per the set safe limit' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'schedules all the jobs at a specific time, per batch' + end + + context 'when the feature flag `sidekiq_push_bulk_in_batches` is disabled' do + include_context 'disable the `sidekiq_push_bulk_in_batches` feature flag' + + context 'when the number of jobs to be enqueued does not exceed the safe limit' do + include_context 'set safe limit beyond the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'schedules all the jobs at a specific time, per batch' + end + + context 'when the number of jobs to be enqueued exceeds safe limit' do + include_context 'set safe limit below the number of jobs to be enqueued' + + it_behaves_like 'enqueues jobs in one go' + it_behaves_like 'returns job_id of all enqueued jobs' + it_behaves_like 'schedules all the jobs at a specific time, per batch' + end + end + end + end + end + end + + describe '.with_status' do + around do |example| + Sidekiq::Testing.fake!(&example) + end + + context 'when the worker does have status_expiration set' do + let(:status_expiration_worker) do + Class.new(worker) do + sidekiq_options status_expiration: 3 + end + end + + it 'uses status_expiration from the worker' do + status_expiration_worker.with_status.perform_async + + expect(Sidekiq::Queues[status_expiration_worker.queue].first).to include('status_expiration' => 3) + expect(Sidekiq::Queues[status_expiration_worker.queue].length).to eq(1) + end + + it 'uses status_expiration from the worker without with_status' do + status_expiration_worker.perform_async + + expect(Sidekiq::Queues[status_expiration_worker.queue].first).to include('status_expiration' => 3) + expect(Sidekiq::Queues[status_expiration_worker.queue].length).to eq(1) + end + end + + context 'when the worker does not have status_expiration set' do + it 'uses the default status_expiration' do + worker.with_status.perform_async + + expect(Sidekiq::Queues[worker.queue].first).to include('status_expiration' => Gitlab::SidekiqStatus::DEFAULT_EXPIRATION) + expect(Sidekiq::Queues[worker.queue].length).to eq(1) + end + + it 'does not set status_expiration without with_status' do + worker.perform_async + + expect(Sidekiq::Queues[worker.queue].first).not_to include('status_expiration') + expect(Sidekiq::Queues[worker.queue].length).to eq(1) end end end diff --git a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb index d4126fe688a..cbffb8f3870 100644 --- a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb +++ b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb @@ -82,8 +82,9 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do nil | 10 | nil 0 | 5 | nil 10 | 0 | 0 - 10 | 5 | 0.5 - 3 | 10 | (10 / 3.to_f) + 10 | 5 | 50.0 + 17 | 3 | 17.65 + 3 | 10 | 333.33 end with_them do diff --git a/spec/workers/database/drop_detached_partitions_worker_spec.rb b/spec/workers/database/drop_detached_partitions_worker_spec.rb index 8693878ddd5..a10fcaaa5d9 100644 --- a/spec/workers/database/drop_detached_partitions_worker_spec.rb +++ b/spec/workers/database/drop_detached_partitions_worker_spec.rb @@ -6,21 +6,19 @@ RSpec.describe Database::DropDetachedPartitionsWorker do describe '#perform' do subject { described_class.new.perform } - let(:monitoring) { instance_double('PartitionMonitoring', report_metrics: nil) } - before do allow(Gitlab::Database::Partitioning).to receive(:drop_detached_partitions) - allow(Gitlab::Database::Partitioning::PartitionMonitoring).to receive(:new).and_return(monitoring) + allow(Gitlab::Database::Partitioning).to receive(:report_metrics) end - it 'delegates to Partitioning.drop_detached_partitions' do + it 'drops detached partitions' do expect(Gitlab::Database::Partitioning).to receive(:drop_detached_partitions) subject end it 'reports partition metrics' do - expect(monitoring).to receive(:report_metrics) + expect(Gitlab::Database::Partitioning).to receive(:report_metrics) subject end diff --git a/spec/workers/database/partition_management_worker_spec.rb b/spec/workers/database/partition_management_worker_spec.rb index 9ded36743a8..e5362e95f48 100644 --- a/spec/workers/database/partition_management_worker_spec.rb +++ b/spec/workers/database/partition_management_worker_spec.rb @@ -6,20 +6,19 @@ RSpec.describe Database::PartitionManagementWorker do describe '#perform' do subject { described_class.new.perform } - let(:monitoring) { instance_double('PartitionMonitoring', report_metrics: nil) } - before do - allow(Gitlab::Database::Partitioning::PartitionMonitoring).to receive(:new).and_return(monitoring) + allow(Gitlab::Database::Partitioning).to receive(:sync_partitions) + allow(Gitlab::Database::Partitioning).to receive(:report_metrics) end - it 'delegates to Partitioning' do + it 'syncs partitions' do expect(Gitlab::Database::Partitioning).to receive(:sync_partitions) subject end it 'reports partition metrics' do - expect(monitoring).to receive(:report_metrics) + expect(Gitlab::Database::Partitioning).to receive(:report_metrics) subject end diff --git a/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb b/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb index d3234f4c212..ae0cb097ebf 100644 --- a/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb +++ b/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb @@ -12,8 +12,8 @@ RSpec.describe DependencyProxy::ImageTtlGroupPolicyWorker do subject { worker.perform } context 'when there are images to expire' do - let_it_be_with_reload(:old_blob) { create(:dependency_proxy_blob, group: group, updated_at: 1.year.ago) } - let_it_be_with_reload(:old_manifest) { create(:dependency_proxy_manifest, group: group, updated_at: 1.year.ago) } + let_it_be_with_reload(:old_blob) { create(:dependency_proxy_blob, group: group, read_at: 1.year.ago) } + let_it_be_with_reload(:old_manifest) { create(:dependency_proxy_manifest, group: group, read_at: 1.year.ago) } let_it_be_with_reload(:new_blob) { create(:dependency_proxy_blob, group: group) } let_it_be_with_reload(:new_manifest) { create(:dependency_proxy_manifest, group: group) } diff --git a/spec/workers/deployments/archive_in_project_worker_spec.rb b/spec/workers/deployments/archive_in_project_worker_spec.rb new file mode 100644 index 00000000000..6435fe8bea1 --- /dev/null +++ b/spec/workers/deployments/archive_in_project_worker_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Deployments::ArchiveInProjectWorker do + subject { described_class.new.perform(deployment&.project_id) } + + describe '#perform' do + let(:deployment) { create(:deployment, :success) } + + it 'executes Deployments::ArchiveInProjectService' do + expect(Deployments::ArchiveInProjectService) + .to receive(:new).with(deployment.project, nil).and_call_original + + subject + end + end +end diff --git a/spec/workers/email_receiver_worker_spec.rb b/spec/workers/email_receiver_worker_spec.rb index 83e13ded7b3..83720ee132b 100644 --- a/spec/workers/email_receiver_worker_spec.rb +++ b/spec/workers/email_receiver_worker_spec.rb @@ -37,6 +37,15 @@ RSpec.describe EmailReceiverWorker, :mailer do expect(email.to).to eq(["jake@adventuretime.ooo"]) expect(email.subject).to include("Rejected") end + + it 'strips out the body before passing to EmailRejectionMailer' do + mail = Mail.new(raw_message) + mail.body = nil + + expect(EmailRejectionMailer).to receive(:rejection).with(anything, mail.encoded, anything).and_call_original + + described_class.new.perform(raw_message) + end end context 'when the error is Gitlab::Email::AutoGeneratedEmailError' do diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb index 6c37c422aed..3e313610054 100644 --- a/spec/workers/emails_on_push_worker_spec.rb +++ b/spec/workers/emails_on_push_worker_spec.rb @@ -139,6 +139,43 @@ RSpec.describe EmailsOnPushWorker, :mailer do perform end + + context 'when SMIME signing is enabled' do + include SmimeHelper + + before :context do + @root_ca = generate_root + @cert = generate_cert(signer_ca: @root_ca) + end + + let(:root_certificate) do + Gitlab::X509::Certificate.new(@root_ca[:key], @root_ca[:cert]) + end + + let(:certificate) do + Gitlab::X509::Certificate.new(@cert[:key], @cert[:cert]) + end + + before do + allow(Gitlab::Email::Hook::SmimeSignatureInterceptor).to receive(:certificate).and_return(certificate) + + Mail.register_interceptor(Gitlab::Email::Hook::SmimeSignatureInterceptor) + end + + after do + Mail.unregister_interceptor(Gitlab::Email::Hook::SmimeSignatureInterceptor) + end + + it 'does not sign the email multiple times' do + perform + + ActionMailer::Base.deliveries.each do |mail| + expect(mail.header['Content-Type'].value).to match('multipart/signed').and match('protocol="application/x-pkcs7-signature"') + + expect(mail.to_s.scan(/Content-Disposition: attachment;\r\n filename=smime.p7s/).size).to eq(1) + end + end + end end context "when recipients are invalid" do diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index 9a4b27997e9..d00243672f9 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -316,6 +316,8 @@ RSpec.describe 'Every Sidekiq worker' do 'IssuableExportCsvWorker' => 3, 'IssuePlacementWorker' => 3, 'IssueRebalancingWorker' => 3, + 'Issues::PlacementWorker' => 3, + 'Issues::RebalancingWorker' => 3, 'IterationsUpdateStatusWorker' => 3, 'JiraConnect::SyncBranchWorker' => 3, 'JiraConnect::SyncBuildsWorker' => 3, diff --git a/spec/workers/integrations/create_external_cross_reference_worker_spec.rb b/spec/workers/integrations/create_external_cross_reference_worker_spec.rb new file mode 100644 index 00000000000..61723f44aa5 --- /dev/null +++ b/spec/workers/integrations/create_external_cross_reference_worker_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::CreateExternalCrossReferenceWorker do + include AfterNextHelpers + using RSpec::Parameterized::TableSyntax + + let_it_be(:project) { create(:jira_project, :repository) } + let_it_be(:author) { create(:user) } + let_it_be(:commit) { project.commit } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let_it_be(:note) { create(:note, project: project) } + let_it_be(:snippet) { create(:project_snippet, project: project) } + + let(:project_id) { project.id } + let(:external_issue_id) { 'JIRA-123' } + let(:mentionable_type) { 'Issue' } + let(:mentionable_id) { issue.id } + let(:author_id) { author.id } + let(:job_args) { [project_id, external_issue_id, mentionable_type, mentionable_id, author_id] } + + def perform + described_class.new.perform(*job_args) + end + + before do + allow(Project).to receive(:find_by_id).and_return(project) + end + + it_behaves_like 'an idempotent worker' do + before do + allow(project.external_issue_tracker).to receive(:create_cross_reference_note) + end + + it 'can run multiple times with the same arguments' do + subject + + expect(project.external_issue_tracker).to have_received(:create_cross_reference_note) + .exactly(worker_exec_times).times + end + end + + it 'has the `until_executed` deduplicate strategy' do + expect(described_class.get_deduplicate_strategy).to eq(:until_executed) + expect(described_class.get_deduplication_options).to include({ including_scheduled: true }) + end + + # These are the only models where we currently support cross-references, + # although this should be expanded to all `Mentionable` models. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/343975 + where(:mentionable_type, :mentionable_id) do + 'Commit' | lazy { commit.id } + 'Issue' | lazy { issue.id } + 'MergeRequest' | lazy { merge_request.id } + 'Note' | lazy { note.id } + 'Snippet' | lazy { snippet.id } + end + + with_them do + it 'creates a cross reference' do + expect(project.external_issue_tracker).to receive(:create_cross_reference_note).with( + be_a(ExternalIssue).and(have_attributes(id: external_issue_id, project: project)), + be_a(mentionable_type.constantize).and(have_attributes(id: mentionable_id)), + be_a(User).and(have_attributes(id: author_id)) + ) + + perform + end + end + + describe 'error handling' do + shared_examples 'does not create a cross reference' do + it 'does not create a cross reference' do + expect(project).not_to receive(:external_issue_tracker) if project + + perform + end + end + + context 'project_id does not exist' do + let(:project_id) { non_existing_record_id } + let(:project) { nil } + + it_behaves_like 'does not create a cross reference' + end + + context 'author_id does not exist' do + let(:author_id) { non_existing_record_id } + + it_behaves_like 'does not create a cross reference' + end + + context 'mentionable_id does not exist' do + let(:mentionable_id) { non_existing_record_id } + + it_behaves_like 'does not create a cross reference' + end + + context 'mentionable_type is not a Mentionable' do + let(:mentionable_type) { 'User' } + + before do + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(kind_of(ArgumentError)) + end + + it_behaves_like 'does not create a cross reference' + end + + context 'mentionable_type is not a defined constant' do + let(:mentionable_type) { 'FooBar' } + + before do + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(kind_of(ArgumentError)) + end + + it_behaves_like 'does not create a cross reference' + end + + context 'mentionable is a Commit and mentionable_id does not exist' do + let(:mentionable_type) { 'Commit' } + let(:mentionable_id) { non_existing_record_id } + + it_behaves_like 'does not create a cross reference' + end + end +end diff --git a/spec/workers/issue_rebalancing_worker_spec.rb b/spec/workers/issue_rebalancing_worker_spec.rb index cba42a1577e..cfb19af05b3 100644 --- a/spec/workers/issue_rebalancing_worker_spec.rb +++ b/spec/workers/issue_rebalancing_worker_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe IssueRebalancingWorker do +RSpec.describe IssueRebalancingWorker, :clean_gitlab_redis_shared_state do describe '#perform' do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } @@ -35,6 +35,20 @@ RSpec.describe IssueRebalancingWorker do described_class.new.perform # all arguments are nil end + + it 'does not schedule a new rebalance if it finished under 1h ago' do + container_type = arguments.second.present? ? ::Gitlab::Issues::Rebalancing::State::PROJECT : ::Gitlab::Issues::Rebalancing::State::NAMESPACE + container_id = arguments.second || arguments.third + + Gitlab::Redis::SharedState.with do |redis| + redis.set(::Gitlab::Issues::Rebalancing::State.send(:recently_finished_key, container_type, container_id), true) + end + + expect(Issues::RelativePositionRebalancingService).not_to receive(:new) + expect(Gitlab::ErrorTracking).not_to receive(:log_exception) + + described_class.new.perform(*arguments) + end end shared_examples 'safely handles non-existent ids' do diff --git a/spec/workers/issues/placement_worker_spec.rb b/spec/workers/issues/placement_worker_spec.rb new file mode 100644 index 00000000000..694cdd2ef37 --- /dev/null +++ b/spec/workers/issues/placement_worker_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Issues::PlacementWorker do + describe '#perform' do + let_it_be(:time) { Time.now.utc } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:author) { create(:user) } + let_it_be(:common_attrs) { { author: author, project: project } } + let_it_be(:unplaced) { common_attrs.merge(relative_position: nil) } + let_it_be_with_reload(:issue) { create(:issue, **unplaced, created_at: time) } + let_it_be_with_reload(:issue_a) { create(:issue, **unplaced, created_at: time - 1.minute) } + let_it_be_with_reload(:issue_b) { create(:issue, **unplaced, created_at: time - 2.minutes) } + let_it_be_with_reload(:issue_c) { create(:issue, **unplaced, created_at: time + 1.minute) } + let_it_be_with_reload(:issue_d) { create(:issue, **unplaced, created_at: time + 2.minutes) } + let_it_be_with_reload(:issue_e) { create(:issue, **common_attrs, relative_position: 10, created_at: time + 1.minute) } + let_it_be_with_reload(:issue_f) { create(:issue, **unplaced, created_at: time + 1.minute) } + + let_it_be(:irrelevant) { create(:issue, relative_position: nil, created_at: time) } + + shared_examples 'running the issue placement worker' do + let(:issue_id) { issue.id } + let(:project_id) { project.id } + + it 'places all issues created at most 5 minutes before this one at the end, most recent last' do + expect { run_worker }.not_to change { irrelevant.reset.relative_position } + + expect(project.issues.order_by_relative_position) + .to eq([issue_e, issue_b, issue_a, issue, issue_c, issue_f, issue_d]) + expect(project.issues.where(relative_position: nil)).not_to exist + end + + it 'schedules rebalancing if needed' do + issue_a.update!(relative_position: RelativePositioning::MAX_POSITION) + + expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, nil, project.group.id) + + run_worker + end + + context 'there are more than QUERY_LIMIT unplaced issues' do + before_all do + # Ensure there are more than N issues in this set + n = described_class::QUERY_LIMIT + create_list(:issue, n - 5, **unplaced) + end + + it 'limits the sweep to QUERY_LIMIT records, and reschedules placement' do + expect(Issue).to receive(:move_nulls_to_end) + .with(have_attributes(count: described_class::QUERY_LIMIT)) + .and_call_original + + expect(described_class).to receive(:perform_async).with(nil, project.id) + + run_worker + + expect(project.issues.where(relative_position: nil)).to exist + end + + it 'is eventually correct' do + prefix = project.issues.where.not(relative_position: nil).order(:relative_position).to_a + moved = project.issues.where.not(id: prefix.map(&:id)) + + run_worker + + expect(project.issues.where(relative_position: nil)).to exist + + run_worker + + expect(project.issues.where(relative_position: nil)).not_to exist + expect(project.issues.order(:relative_position)).to eq(prefix + moved.order(:created_at, :id)) + end + end + + context 'we are passed bad IDs' do + let(:issue_id) { non_existing_record_id } + let(:project_id) { non_existing_record_id } + + def max_positions_by_project + Issue + .group(:project_id) + .pluck(:project_id, Issue.arel_table[:relative_position].maximum.as('max_relative_position')) + .to_h + end + + it 'does move any issues to the end' do + expect { run_worker }.not_to change { max_positions_by_project } + end + + context 'the project_id refers to an empty project' do + let!(:project_id) { create(:project).id } + + it 'does move any issues to the end' do + expect { run_worker }.not_to change { max_positions_by_project } + end + end + end + + it 'anticipates the failure to place the issues, and schedules rebalancing' do + allow(Issue).to receive(:move_nulls_to_end) { raise RelativePositioning::NoSpaceLeft } + + expect(Issues::RebalancingWorker).to receive(:perform_async).with(nil, nil, project.group.id) + expect(Gitlab::ErrorTracking) + .to receive(:log_exception) + .with(RelativePositioning::NoSpaceLeft, worker_arguments) + + run_worker + end + end + + context 'passing an issue ID' do + def run_worker + described_class.new.perform(issue_id) + end + + let(:worker_arguments) { { issue_id: issue_id, project_id: nil } } + + it_behaves_like 'running the issue placement worker' + + context 'when block_issue_repositioning is enabled' do + let(:issue_id) { issue.id } + let(:project_id) { project.id } + + before do + stub_feature_flags(block_issue_repositioning: group) + end + + it 'does not run repositioning tasks' do + expect { run_worker }.not_to change { issue.reset.relative_position } + end + end + end + + context 'passing a project ID' do + def run_worker + described_class.new.perform(nil, project_id) + end + + let(:worker_arguments) { { issue_id: nil, project_id: project_id } } + + it_behaves_like 'running the issue placement worker' + end + end + + it 'has the `until_executed` deduplicate strategy' do + expect(described_class.get_deduplicate_strategy).to eq(:until_executed) + expect(described_class.get_deduplication_options).to include({ including_scheduled: true }) + end +end diff --git a/spec/workers/issues/rebalancing_worker_spec.rb b/spec/workers/issues/rebalancing_worker_spec.rb new file mode 100644 index 00000000000..438edd85f66 --- /dev/null +++ b/spec/workers/issues/rebalancing_worker_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Issues::RebalancingWorker do + describe '#perform' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:issue) { create(:issue, project: project) } + + shared_examples 'running the worker' do + it 'runs an instance of Issues::RelativePositionRebalancingService' do + service = double(execute: nil) + service_param = arguments.second.present? ? kind_of(Project.id_in([project]).class) : kind_of(group&.all_projects.class) + + expect(Issues::RelativePositionRebalancingService).to receive(:new).with(service_param).and_return(service) + + described_class.new.perform(*arguments) + end + + it 'anticipates there being too many concurent rebalances' do + service = double + service_param = arguments.second.present? ? kind_of(Project.id_in([project]).class) : kind_of(group&.all_projects.class) + + allow(service).to receive(:execute).and_raise(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances) + expect(Issues::RelativePositionRebalancingService).to receive(:new).with(service_param).and_return(service) + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances, include(project_id: arguments.second, root_namespace_id: arguments.third)) + + described_class.new.perform(*arguments) + end + + it 'takes no action if the value is nil' do + expect(Issues::RelativePositionRebalancingService).not_to receive(:new) + expect(Gitlab::ErrorTracking).not_to receive(:log_exception) + + described_class.new.perform # all arguments are nil + end + end + + shared_examples 'safely handles non-existent ids' do + it 'anticipates the inability to find the issue' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(ArgumentError, include(project_id: arguments.second, root_namespace_id: arguments.third)) + expect(Issues::RelativePositionRebalancingService).not_to receive(:new) + + described_class.new.perform(*arguments) + end + end + + context 'without root_namespace param' do + it_behaves_like 'running the worker' do + let(:arguments) { [-1, project.id] } + end + + it_behaves_like 'safely handles non-existent ids' do + let(:arguments) { [nil, -1] } + end + + include_examples 'an idempotent worker' do + let(:job_args) { [-1, project.id] } + end + + include_examples 'an idempotent worker' do + let(:job_args) { [nil, -1] } + end + end + + context 'with root_namespace param' do + it_behaves_like 'running the worker' do + let(:arguments) { [nil, nil, group.id] } + end + + it_behaves_like 'safely handles non-existent ids' do + let(:arguments) { [nil, nil, -1] } + end + + include_examples 'an idempotent worker' do + let(:job_args) { [nil, nil, group.id] } + end + + include_examples 'an idempotent worker' do + let(:job_args) { [nil, nil, -1] } + end + end + end + + it 'has the `until_executed` deduplicate strategy' do + expect(described_class.get_deduplicate_strategy).to eq(:until_executed) + expect(described_class.get_deduplication_options).to include({ including_scheduled: true }) + end +end diff --git a/spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb b/spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb new file mode 100644 index 00000000000..02d1241d2ba --- /dev/null +++ b/spec/workers/issues/reschedule_stuck_issue_rebalances_worker_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Issues::RescheduleStuckIssueRebalancesWorker, :clean_gitlab_redis_shared_state do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + subject(:worker) { described_class.new } + + describe '#perform' do + it 'does not schedule a rebalance' do + expect(IssueRebalancingWorker).not_to receive(:perform_async) + + worker.perform + end + + it 'schedules a rebalance in case there are any rebalances started' do + expect(::Gitlab::Issues::Rebalancing::State).to receive(:fetch_rebalancing_groups_and_projects).and_return([[group.id], [project.id]]) + expect(IssueRebalancingWorker).to receive(:bulk_perform_async).with([[nil, nil, group.id]]).once + expect(IssueRebalancingWorker).to receive(:bulk_perform_async).with([[nil, project.id, nil]]).once + + worker.perform + end + end +end diff --git a/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb b/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb new file mode 100644 index 00000000000..544be2a69a6 --- /dev/null +++ b/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe LooseForeignKeys::CleanupWorker do + include MigrationsHelpers + + def create_table_structure + migration = ActiveRecord::Migration.new.extend(Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers) + + migration.create_table :_test_loose_fk_parent_table_1 + migration.create_table :_test_loose_fk_parent_table_2 + + migration.create_table :_test_loose_fk_child_table_1_1 do |t| + t.bigint :parent_id + end + + migration.create_table :_test_loose_fk_child_table_1_2 do |t| + t.bigint :parent_id_with_different_column + end + + migration.create_table :_test_loose_fk_child_table_2_1 do |t| + t.bigint :parent_id + end + + migration.track_record_deletions(:_test_loose_fk_parent_table_1) + migration.track_record_deletions(:_test_loose_fk_parent_table_2) + end + + let!(:parent_model_1) do + Class.new(ApplicationRecord) do + self.table_name = '_test_loose_fk_parent_table_1' + + include LooseForeignKey + + loose_foreign_key :_test_loose_fk_child_table_1_1, :parent_id, on_delete: :async_delete + loose_foreign_key :_test_loose_fk_child_table_1_2, :parent_id_with_different_column, on_delete: :async_nullify + end + end + + let!(:parent_model_2) do + Class.new(ApplicationRecord) do + self.table_name = '_test_loose_fk_parent_table_2' + + include LooseForeignKey + + loose_foreign_key :_test_loose_fk_child_table_2_1, :parent_id, on_delete: :async_delete + end + end + + let!(:child_model_1) do + Class.new(ApplicationRecord) do + self.table_name = '_test_loose_fk_child_table_1_1' + end + end + + let!(:child_model_2) do + Class.new(ApplicationRecord) do + self.table_name = '_test_loose_fk_child_table_1_2' + end + end + + let!(:child_model_3) do + Class.new(ApplicationRecord) do + self.table_name = '_test_loose_fk_child_table_2_1' + end + end + + let(:loose_fk_parent_table_1) { table(:_test_loose_fk_parent_table_1) } + let(:loose_fk_parent_table_2) { table(:_test_loose_fk_parent_table_2) } + let(:loose_fk_child_table_1_1) { table(:_test_loose_fk_child_table_1_1) } + let(:loose_fk_child_table_1_2) { table(:_test_loose_fk_child_table_1_2) } + let(:loose_fk_child_table_2_1) { table(:_test_loose_fk_child_table_2_1) } + + before(:all) do + create_table_structure + end + + after(:all) do + migration = ActiveRecord::Migration.new + + migration.drop_table :_test_loose_fk_parent_table_1 + migration.drop_table :_test_loose_fk_parent_table_2 + migration.drop_table :_test_loose_fk_child_table_1_1 + migration.drop_table :_test_loose_fk_child_table_1_2 + migration.drop_table :_test_loose_fk_child_table_2_1 + end + + before do + parent_record_1 = loose_fk_parent_table_1.create! + loose_fk_child_table_1_1.create!(parent_id: parent_record_1.id) + loose_fk_child_table_1_2.create!(parent_id_with_different_column: parent_record_1.id) + + parent_record_2 = loose_fk_parent_table_1.create! + 2.times { loose_fk_child_table_1_1.create!(parent_id: parent_record_2.id) } + 3.times { loose_fk_child_table_1_2.create!(parent_id_with_different_column: parent_record_2.id) } + + parent_record_3 = loose_fk_parent_table_2.create! + 5.times { loose_fk_child_table_2_1.create!(parent_id: parent_record_3.id) } + + parent_model_1.delete_all + parent_model_2.delete_all + end + + it 'cleans up all rows' do + described_class.new.perform + + expect(loose_fk_child_table_1_1.count).to eq(0) + expect(loose_fk_child_table_1_2.where(parent_id_with_different_column: nil).count).to eq(4) + expect(loose_fk_child_table_2_1.count).to eq(0) + end + + context 'when deleting in batches' do + before do + stub_const('LooseForeignKeys::CleanupWorker::BATCH_SIZE', 2) + end + + it 'cleans up all rows' do + expect(LooseForeignKeys::BatchCleanerService).to receive(:new).exactly(:twice).and_call_original + + described_class.new.perform + + expect(loose_fk_child_table_1_1.count).to eq(0) + expect(loose_fk_child_table_1_2.where(parent_id_with_different_column: nil).count).to eq(4) + expect(loose_fk_child_table_2_1.count).to eq(0) + end + end + + context 'when the deleted rows count limit have been reached' do + def count_deletable_rows + loose_fk_child_table_1_1.count + loose_fk_child_table_2_1.count + end + + before do + stub_const('LooseForeignKeys::ModificationTracker::MAX_DELETES', 2) + stub_const('LooseForeignKeys::CleanerService::DELETE_LIMIT', 1) + end + + it 'cleans up 2 rows' do + expect { described_class.new.perform }.to change { count_deletable_rows }.by(-2) + end + end + + context 'when the loose_foreign_key_cleanup feature flag is off' do + before do + stub_feature_flags(loose_foreign_key_cleanup: false) + end + + it 'does nothing' do + expect { described_class.new.perform }.not_to change { LooseForeignKeys::DeletedRecord.status_processed.count } + end + end +end diff --git a/spec/workers/namespaces/invite_team_email_worker_spec.rb b/spec/workers/namespaces/invite_team_email_worker_spec.rb new file mode 100644 index 00000000000..47fdff9a8ef --- /dev/null +++ b/spec/workers/namespaces/invite_team_email_worker_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespaces::InviteTeamEmailWorker do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + it 'sends the email' do + expect(Namespaces::InviteTeamEmailService).to receive(:send_email).with(user, group).once + subject.perform(group.id, user.id) + end + + context 'when user id is non-existent' do + it 'does not send the email' do + expect(Namespaces::InviteTeamEmailService).not_to receive(:send_email) + subject.perform(group.id, non_existing_record_id) + end + end + + context 'when group id is non-existent' do + it 'does not send the email' do + expect(Namespaces::InviteTeamEmailService).not_to receive(:send_email) + subject.perform(non_existing_record_id, user.id) + end + end +end diff --git a/spec/workers/packages/maven/metadata/sync_worker_spec.rb b/spec/workers/packages/maven/metadata/sync_worker_spec.rb index 10482b3e327..4b3cc6f964b 100644 --- a/spec/workers/packages/maven/metadata/sync_worker_spec.rb +++ b/spec/workers/packages/maven/metadata/sync_worker_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Packages::Maven::Metadata::SyncWorker, type: :worker do let(:versions) { %w[1.2 1.1 2.1 3.0-SNAPSHOT] } let(:worker) { described_class.new } + let(:data_struct) { Struct.new(:release, :latest, :versions, keyword_init: true) } describe '#perform' do let(:user) { create(:user) } @@ -197,7 +198,7 @@ RSpec.describe Packages::Maven::Metadata::SyncWorker, type: :worker do def versions_from(xml_content) xml_doc = Nokogiri::XML(xml_content) - OpenStruct.new( + data_struct.new( release: xml_doc.xpath('//metadata/versioning/release').first.content, latest: xml_doc.xpath('//metadata/versioning/latest').first.content, versions: xml_doc.xpath('//metadata/versioning/versions/version').map(&:content) diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 039f86f1911..42e39c51a88 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -91,14 +91,6 @@ RSpec.describe PostReceive do perform end - - it 'tracks an event for the empty_repo_upload experiment', :experiment do - expect_next_instance_of(EmptyRepoUploadExperiment) do |e| - expect(e).to receive(:track_initial_write) - end - - perform - end end shared_examples 'not updating remote mirrors' do diff --git a/spec/workers/propagate_integration_group_worker_spec.rb b/spec/workers/propagate_integration_group_worker_spec.rb index 9d46534df4f..60442438a1d 100644 --- a/spec/workers/propagate_integration_group_worker_spec.rb +++ b/spec/workers/propagate_integration_group_worker_spec.rb @@ -22,7 +22,7 @@ RSpec.describe PropagateIntegrationGroupWorker do end context 'with a group integration' do - let_it_be(:integration) { create(:redmine_integration, group: group, project: nil) } + let_it_be(:integration) { create(:redmine_integration, :group, group: group) } it 'calls to BulkCreateIntegrationService' do expect(BulkCreateIntegrationService).to receive(:new) diff --git a/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb b/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb index 8a231d4104c..c9a7bfaa8b6 100644 --- a/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb +++ b/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe PropagateIntegrationInheritDescendantWorker do let_it_be(:group) { create(:group) } let_it_be(:subgroup) { create(:group, parent: group) } - let_it_be(:group_integration) { create(:redmine_integration, group: group, project: nil) } - let_it_be(:subgroup_integration) { create(:redmine_integration, group: subgroup, project: nil, inherit_from_id: group_integration.id) } + let_it_be(:group_integration) { create(:redmine_integration, :group, group: group) } + let_it_be(:subgroup_integration) { create(:redmine_integration, :group, group: subgroup, inherit_from_id: group_integration.id) } it_behaves_like 'an idempotent worker' do let(:job_args) { [group_integration.id, subgroup_integration.id, subgroup_integration.id] } diff --git a/spec/workers/propagate_integration_project_worker_spec.rb b/spec/workers/propagate_integration_project_worker_spec.rb index 312631252cc..c7adf1b826f 100644 --- a/spec/workers/propagate_integration_project_worker_spec.rb +++ b/spec/workers/propagate_integration_project_worker_spec.rb @@ -22,7 +22,7 @@ RSpec.describe PropagateIntegrationProjectWorker do end context 'with a group integration' do - let_it_be(:integration) { create(:redmine_integration, group: group, project: nil) } + let_it_be(:integration) { create(:redmine_integration, :group, group: group) } it 'calls to BulkCreateIntegrationService' do expect(BulkCreateIntegrationService).to receive(:new) diff --git a/spec/workers/ssh_keys/expired_notification_worker_spec.rb b/spec/workers/ssh_keys/expired_notification_worker_spec.rb index 109d24f03ab..be38391ff8c 100644 --- a/spec/workers/ssh_keys/expired_notification_worker_spec.rb +++ b/spec/workers/ssh_keys/expired_notification_worker_spec.rb @@ -20,7 +20,7 @@ RSpec.describe SshKeys::ExpiredNotificationWorker, type: :worker do stub_const("SshKeys::ExpiredNotificationWorker::BATCH_SIZE", 5) end - let_it_be_with_reload(:keys) { create_list(:key, 20, expires_at: 3.days.ago, user: user) } + let_it_be_with_reload(:keys) { create_list(:key, 20, expires_at: Time.current, user: user) } it 'updates all keys regardless of batch size' do worker.perform @@ -54,8 +54,8 @@ RSpec.describe SshKeys::ExpiredNotificationWorker, type: :worker do context 'when key has expired in the past' do let_it_be(:expired_past) { create(:key, expires_at: 1.day.ago, user: user) } - it 'does update notified column' do - expect { worker.perform }.to change { expired_past.reload.expiry_notification_delivered_at } + it 'does not update notified column' do + expect { worker.perform }.not_to change { expired_past.reload.expiry_notification_delivered_at } end context 'when key has already been notified of expiration' do diff --git a/spec/workers/tasks_to_be_done/create_worker_spec.rb b/spec/workers/tasks_to_be_done/create_worker_spec.rb new file mode 100644 index 00000000000..a158872273f --- /dev/null +++ b/spec/workers/tasks_to_be_done/create_worker_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TasksToBeDone::CreateWorker do + let_it_be(:member_task) { create(:member_task, tasks: MemberTask::TASKS.values) } + let_it_be(:current_user) { create(:user) } + + let(:assignee_ids) { [1, 2] } + let(:job_args) { [member_task.id, current_user.id, assignee_ids] } + + before do + member_task.project.group.add_owner(current_user) + end + + describe '.perform' do + it 'executes the task services for all tasks to be done', :aggregate_failures do + MemberTask::TASKS.each_key do |task| + service_class = "TasksToBeDone::Create#{task.to_s.camelize}TaskService".constantize + + expect(service_class) + .to receive(:new) + .with(project: member_task.project, current_user: current_user, assignee_ids: assignee_ids) + .and_call_original + end + + expect { described_class.new.perform(*job_args) }.to change(Issue, :count).by(3) + end + end + + include_examples 'an idempotent worker' do + it 'creates 3 task issues' do + expect { subject }.to change(Issue, :count).by(3) + end + end +end diff --git a/spec/workers/users/deactivate_dormant_users_worker_spec.rb b/spec/workers/users/deactivate_dormant_users_worker_spec.rb index 934c497c79a..20cd55e19eb 100644 --- a/spec/workers/users/deactivate_dormant_users_worker_spec.rb +++ b/spec/workers/users/deactivate_dormant_users_worker_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Users::DeactivateDormantUsersWorker do + using RSpec::Parameterized::TableSyntax + describe '#perform' do let_it_be(:dormant) { create(:user, last_activity_on: User::MINIMUM_INACTIVE_DAYS.days.ago.to_date) } let_it_be(:inactive) { create(:user, last_activity_on: nil) } @@ -22,12 +24,12 @@ RSpec.describe Users::DeactivateDormantUsersWorker do context 'when automatic deactivation of dormant users is enabled' do before do stub_application_setting(deactivate_dormant_users: true) + stub_const("#{described_class.name}::PAUSE_SECONDS", 0) end it 'deactivates dormant users' do freeze_time do stub_const("#{described_class.name}::BATCH_SIZE", 1) - stub_const("#{described_class.name}::PAUSE_SECONDS", 0) expect(worker).to receive(:sleep).twice @@ -37,6 +39,38 @@ RSpec.describe Users::DeactivateDormantUsersWorker do expect(User.with_no_activity.count).to eq(0) end end + + where(:user_type, :expected_state) do + :human | 'deactivated' + :support_bot | 'active' + :alert_bot | 'active' + :visual_review_bot | 'active' + :service_user | 'deactivated' + :ghost | 'active' + :project_bot | 'active' + :migration_bot | 'active' + :security_bot | 'active' + :automation_bot | 'active' + end + with_them do + it 'deactivates certain user types' do + user = create(:user, user_type: user_type, state: :active, last_activity_on: User::MINIMUM_INACTIVE_DAYS.days.ago.to_date) + + worker.perform + + expect(user.reload.state).to eq(expected_state) + end + end + + it 'does not deactivate non-active users' do + human_user = create(:user, user_type: :human, state: :blocked, last_activity_on: User::MINIMUM_INACTIVE_DAYS.days.ago.to_date) + service_user = create(:user, user_type: :service_user, state: :blocked, last_activity_on: User::MINIMUM_INACTIVE_DAYS.days.ago.to_date) + + worker.perform + + expect(human_user.reload.state).to eq('blocked') + expect(service_user.reload.state).to eq('blocked') + end end context 'when automatic deactivation of dormant users is disabled' do -- cgit v1.2.3