From 4233d3aa86fe94e6288279aa55d42ed95bfe753c Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 29 Apr 2020 12:10:00 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- .gitlab/ci/frontend.gitlab-ci.yml | 7 +- app/assets/javascripts/groups/components/app.vue | 7 +- .../repository/components/breadcrumbs.vue | 4 +- .../repository/components/table/index.vue | 2 +- .../repository/components/table/parent_row.vue | 3 +- .../repository/components/table/row.vue | 2 +- app/assets/javascripts/repository/index.js | 5 +- app/assets/javascripts/repository/log_tree.js | 8 +- .../javascripts/repository/mixins/get_ref.js | 8 + .../repository/queries/getRef.query.graphql | 1 + app/assets/javascripts/repository/router.js | 2 +- app/helpers/tree_helper.rb | 1 + app/services/emails/base_service.rb | 2 + app/services/projects/alerting/notify_service.rb | 17 +- .../incident_management/process_alert_worker.rb | 25 +- changelogs/unreleased/214547_expose_web_url.yml | 5 + changelogs/unreleased/ph-215917-escapeRef.yml | 5 + changelogs/unreleased/refactor-ee-app-services.yml | 5 + danger/changelog/Dangerfile | 3 +- doc/api/branches.md | 5 +- doc/api/graphql/reference/gitlab_schema.graphql | 135 ++++++ doc/api/graphql/reference/gitlab_schema.json | 351 ++++++++++++++ doc/api/graphql/reference/index.md | 15 + doc/api/projects.md | 4 +- doc/development/fe_guide/vue.md | 13 + doc/development/fe_guide/vue3_migration.md | 109 +++++ doc/development/geo/framework.md | 223 +++++++-- doc/user/project/settings/index.md | 2 +- lib/api/entities/branch.rb | 6 + lib/gitlab/alert_management/alert_params.rb | 23 + .../fixtures/api/schemas/public_api/v4/branch.json | 6 +- spec/frontend/groups/components/app_spec.js | 507 ++++++++++++++++++++ .../groups/components/group_folder_spec.js | 65 +++ spec/frontend/groups/components/group_item_spec.js | 215 +++++++++ spec/frontend/groups/components/groups_spec.js | 72 +++ .../groups/components/item_actions_spec.js | 84 ++++ spec/frontend/groups/components/item_caret_spec.js | 38 ++ spec/frontend/groups/components/item_stats_spec.js | 128 +++++ .../groups/components/item_stats_value_spec.js | 82 ++++ .../groups/components/item_type_icon_spec.js | 53 ++ spec/frontend/groups/mock_data.js | 398 +++++++++++++++ .../frontend/groups/service/groups_service_spec.js | 42 ++ spec/frontend/groups/store/groups_store_spec.js | 123 +++++ .../repository/components/table/row_spec.js | 2 +- spec/frontend/repository/log_tree_spec.js | 28 +- spec/frontend/repository/router_spec.js | 13 +- .../mr_widget_pipeline_container_spec.js | 100 ++++ spec/javascripts/groups/components/app_spec.js | 533 --------------------- .../groups/components/group_folder_spec.js | 67 --- .../groups/components/group_item_spec.js | 218 --------- spec/javascripts/groups/components/groups_spec.js | 76 --- .../groups/components/item_actions_spec.js | 84 ---- .../groups/components/item_caret_spec.js | 38 -- .../groups/components/item_stats_spec.js | 128 ----- .../groups/components/item_stats_value_spec.js | 82 ---- .../groups/components/item_type_icon_spec.js | 58 --- spec/javascripts/groups/mock_data.js | 398 --------------- .../groups/service/groups_service_spec.js | 42 -- spec/javascripts/groups/store/groups_store_spec.js | 123 ----- .../mr_widget_pipeline_container_spec.js | 99 ---- spec/lib/api/entities/branch_spec.rb | 28 ++ .../gitlab/alert_management/alert_params_spec.rb | 45 ++ .../projects/alerting/notify_service_spec.rb | 46 +- .../process_alert_worker_spec.rb | 67 ++- 64 files changed, 3031 insertions(+), 2055 deletions(-) create mode 100644 changelogs/unreleased/214547_expose_web_url.yml create mode 100644 changelogs/unreleased/ph-215917-escapeRef.yml create mode 100644 changelogs/unreleased/refactor-ee-app-services.yml create mode 100644 doc/development/fe_guide/vue3_migration.md create mode 100644 lib/gitlab/alert_management/alert_params.rb create mode 100644 spec/frontend/groups/components/app_spec.js create mode 100644 spec/frontend/groups/components/group_folder_spec.js create mode 100644 spec/frontend/groups/components/group_item_spec.js create mode 100644 spec/frontend/groups/components/groups_spec.js create mode 100644 spec/frontend/groups/components/item_actions_spec.js create mode 100644 spec/frontend/groups/components/item_caret_spec.js create mode 100644 spec/frontend/groups/components/item_stats_spec.js create mode 100644 spec/frontend/groups/components/item_stats_value_spec.js create mode 100644 spec/frontend/groups/components/item_type_icon_spec.js create mode 100644 spec/frontend/groups/mock_data.js create mode 100644 spec/frontend/groups/service/groups_service_spec.js create mode 100644 spec/frontend/groups/store/groups_store_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js delete mode 100644 spec/javascripts/groups/components/app_spec.js delete mode 100644 spec/javascripts/groups/components/group_folder_spec.js delete mode 100644 spec/javascripts/groups/components/group_item_spec.js delete mode 100644 spec/javascripts/groups/components/groups_spec.js delete mode 100644 spec/javascripts/groups/components/item_actions_spec.js delete mode 100644 spec/javascripts/groups/components/item_caret_spec.js delete mode 100644 spec/javascripts/groups/components/item_stats_spec.js delete mode 100644 spec/javascripts/groups/components/item_stats_value_spec.js delete mode 100644 spec/javascripts/groups/components/item_type_icon_spec.js delete mode 100644 spec/javascripts/groups/mock_data.js delete mode 100644 spec/javascripts/groups/service/groups_service_spec.js delete mode 100644 spec/javascripts/groups/store/groups_store_spec.js delete mode 100644 spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js create mode 100644 spec/lib/api/entities/branch_spec.rb create mode 100644 spec/lib/gitlab/alert_management/alert_params_spec.rb diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index 293beb60cfd..3e2ccb6fdfe 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -67,15 +67,12 @@ build-assets-image: stage: build-images needs: ["gitlab:assets:compile pull-cache"] variables: - GIT_STRATEGY: none + GIT_DEPTH: "1" script: - - wget -O ./build_assets_image "${CI_PROJECT_URL}/raw/${CI_COMMIT_SHA}/scripts/build_assets_image" - - wget -O ./Dockerfile.assets "${CI_PROJECT_URL}/raw/${CI_COMMIT_SHA}/Dockerfile.assets" - - chmod +x build_assets_image # TODO: Change the image tag to be the MD5 of assets files and skip image building if the image exists # We'll also need to pass GITLAB_ASSETS_TAG to the trigerred omnibus-gitlab pipeline similarly to how we do it for trigerred CNG pipelines # https://gitlab.com/gitlab-org/gitlab/issues/208389 - - ./build_assets_image + - scripts/build_assets_image .compile-assets-metadata: extends: diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index ce6591e85cf..0b401f4d732 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -111,8 +111,8 @@ export default { const filterGroupsBy = getParameterByName('filter') || null; this.isLoading = true; - // eslint-disable-next-line promise/catch-or-return - this.fetchGroups({ + + return this.fetchGroups({ page, filterGroupsBy, sortBy, @@ -126,8 +126,7 @@ export default { fetchPage(page, filterGroupsBy, sortBy, archived) { this.isLoading = true; - // eslint-disable-next-line promise/catch-or-return - this.fetchGroups({ + return this.fetchGroups({ page, filterGroupsBy, sortBy, diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index d78b2d9d962..886e9d76cca 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -108,14 +108,14 @@ export default { return acc.concat({ name, path, - to: `/-/tree/${joinPaths(escapeFileUrl(this.ref), path)}`, + to: `/-/tree/${joinPaths(this.escapedRef, path)}`, }); }, [ { name: this.projectShortPath, path: '/', - to: `/-/tree/${escapeFileUrl(this.ref)}/`, + to: `/-/tree/${this.escapedRef}/`, }, ], ); diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 2ba170998e8..c8549180a25 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -81,7 +81,7 @@ export default { diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index 0a8ee5f2fc5..b4095e00884 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -1,6 +1,5 @@ + + +``` + +```js +// MyAwesomeComponent.spec.js + +import SomeChildComponent from '~/some_child_component.vue' + +shallowMount(MyAwesomeComponent, { + stubs: { + SomeChildComponent + } +}) +``` diff --git a/doc/development/geo/framework.md b/doc/development/geo/framework.md index 83809d1fd3d..a2ee52cbc7c 100644 --- a/doc/development/geo/framework.md +++ b/doc/development/geo/framework.md @@ -161,49 +161,7 @@ state. For example, to add support for files referenced by a `Widget` model with a `widgets` table, you would perform the following steps: -1. Add verification state fields to the `widgets` table so the Geo primary can - track verification state: - - ```ruby - # frozen_string_literal: true - - class AddVerificationStateToWidgets < ActiveRecord::Migration[6.0] - DOWNTIME = false - - def change - add_column :widgets, :verification_retry_at, :datetime_with_timezone - add_column :widgets, :verified_at, :datetime_with_timezone - add_column :widgets, :verification_checksum, :string - add_column :widgets, :verification_failure, :string - add_column :widgets, :verification_retry_count, :integer - end - end - ``` - -1. Add a partial index on `verification_failure` and `verification_checksum` to ensure - re-verification can be performed efficiently: - - ```ruby - # frozen_string_literal: true - - class AddVerificationFailureIndexToWidgets < ActiveRecord::Migration[6.0] - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - - disable_ddl_transaction! - - def up - add_concurrent_index :widgets, :verification_failure, where: "(verification_failure IS NOT NULL)", name: "widgets_verification_failure_partial" - add_concurrent_index :widgets, :verification_checksum, where: "(verification_checksum IS NOT NULL)", name: "widgets_verification_checksum_partial" - end - - def down - remove_concurrent_index :widgets, :verification_failure - remove_concurrent_index :widgets, :verification_checksum - end - end - ``` +#### Replication 1. Include `Gitlab::Geo::ReplicableModel` in the `Widget` class, and specify the Replicator class `with_replicator Geo::WidgetReplicator`. @@ -350,11 +308,53 @@ For example, to add support for files referenced by a `Widget` model with a end ``` -Widget files should now be replicated and verified by Geo! +Widgets should now be replicated by Geo! + +#### Verification + +1. Add verification state fields to the `widgets` table so the Geo primary can + track verification state: + + ```ruby + # frozen_string_literal: true + + class AddVerificationStateToWidgets < ActiveRecord::Migration[6.0] + DOWNTIME = false -### Verification statistics with Blob Replicator Strategy + def change + add_column :widgets, :verification_retry_at, :datetime_with_timezone + add_column :widgets, :verified_at, :datetime_with_timezone + add_column :widgets, :verification_checksum, :string + add_column :widgets, :verification_failure, :string + add_column :widgets, :verification_retry_count, :integer + end + end + ``` -GitLab Geo stores statistic data in the `geo_node_statuses` table. +1. Add a partial index on `verification_failure` and `verification_checksum` to ensure + re-verification can be performed efficiently: + + ```ruby + # frozen_string_literal: true + + class AddVerificationFailureIndexToWidgets < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :widgets, :verification_failure, where: "(verification_failure IS NOT NULL)", name: "widgets_verification_failure_partial" + add_concurrent_index :widgets, :verification_checksum, where: "(verification_checksum IS NOT NULL)", name: "widgets_verification_checksum_partial" + end + + def down + remove_concurrent_index :widgets, :verification_failure + remove_concurrent_index :widgets, :verification_checksum + end + end + ``` 1. Add fields `widget_count`, `widget_checksummed_count`, and `widget_checksum_failed_count` to `GeoNodeStatus#RESOURCE_STATUS_FIELDS` array in `ee/app/models/geo_node_status.rb`. @@ -378,3 +378,134 @@ GitLab Geo stores statistic data in the `geo_node_statuses` table. 1. Update `Sidekiq metrics` table in `doc/administration/monitoring/prometheus/gitlab_metrics.md` with new fields. 1. Update `GET /geo_nodes/status` example response in `doc/api/geo_nodes.md` with new fields. 1. Update `ee/spec/models/geo_node_status_spec.rb` and `ee/spec/factories/geo_node_statuses.rb` with new fields. + +To do: Add verification on secondaries. + +Widgets should now be verified by Geo! + +#### GraphQL API + +1. Add a new field to `GeoNodeType` in + `ee/app/graphql/types/geo/geo_node_type.rb`: + + ```ruby + field :widget_registries, ::Types::Geo::WidgetRegistryType.connection_type, + null: true, + resolver: ::Resolvers::Geo::WidgetRegistriesResolver, + description: 'Find widget registries on this Geo node', + feature_flag: :geo_self_service_framework + ``` + +1. Add the new `widget_registries` field name to the `expected_fields` array in + `ee/spec/graphql/types/geo/geo_node_type_spec.rb`. + +1. Create `ee/app/graphql/resolvers/geo/widget_registries_resolver.rb`: + + ```ruby + # frozen_string_literal: true + + module Resolvers + module Geo + class WidgetRegistriesResolver < BaseResolver + include RegistriesResolver + end + end + end + ``` + +1. Create `ee/spec/graphql/resolvers/geo/widget_registries_resolver_spec.rb`: + + ```ruby + # frozen_string_literal: true + + require 'spec_helper' + + describe Resolvers::Geo::WidgetRegistriesResolver do + it_behaves_like 'a Geo registries resolver', :widget_registry + end + ``` + +1. Create `ee/app/finders/geo/widget_registry_finder.rb`: + + ```ruby + # frozen_string_literal: true + + module Geo + class WidgetRegistryFinder + include FrameworkRegistryFinder + end + end + ``` + +1. Create `ee/spec/finders/geo/widget_registry_finder_spec.rb`: + + ```ruby + # frozen_string_literal: true + + require 'spec_helper' + + describe Geo::WidgetRegistryFinder do + it_behaves_like 'a framework registry finder', :widget_registry + end + ``` + +1. Create `ee/app/graphql/types/geo/package_file_registry_type.rb`: + + ```ruby + # frozen_string_literal: true + + module Types + module Geo + # rubocop:disable Graphql/AuthorizeTypes because it is included + class WidgetRegistryType < BaseObject + include ::Types::Geo::RegistryType + + graphql_name 'WidgetRegistry' + description 'Represents the sync and verification state of a widget' + + field :widget_id, GraphQL::ID_TYPE, null: false, description: 'ID of the Widget' + end + end + end + ``` + +1. Create `ee/spec/graphql/types/geo/widget_registry_type_spec.rb`: + + ```ruby + # frozen_string_literal: true + + require 'spec_helper' + + describe GitlabSchema.types['WidgetRegistry'] do + it_behaves_like 'a Geo registry type' + + it 'has the expected fields (other than those included in RegistryType)' do + expected_fields = %i[widget_id] + + expect(described_class).to have_graphql_fields(*expected_fields).at_least + end + end + ``` + +1. Add integration tests for providing Widget registry data to the frontend via + the GraphQL API, by duplicating and modifying the following shared examples + in `ee/spec/requests/api/graphql/geo/registries_spec.rb`: + + ```ruby + it_behaves_like 'gets registries for', { + field_name: 'widgetRegistries', + registry_class_name: 'WidgetRegistry', + registry_factory: :widget_registry, + registry_foreign_key_field_name: 'widgetId' + } + ``` + +Individual widget synchronization and verification data should now be available +via the GraphQL API! + +#### Admin UI + +To do. + +Widget sync and verification data (aggregate and individual) should now be +available in the Admin UI! diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index 6cb97e2e2a8..ae87945c684 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -257,7 +257,7 @@ To do so: 1. Confirm the action by typing the project's path as instructed. NOTE: **Note:** -Only project maintainers have the [permissions](../../permissions.md#project-members-permissions) +Only project owners have the [permissions](../../permissions.md#project-members-permissions) to remove a fork relationship. ## Operations settings diff --git a/lib/api/entities/branch.rb b/lib/api/entities/branch.rb index 1d5017ac702..f9d06082ad6 100644 --- a/lib/api/entities/branch.rb +++ b/lib/api/entities/branch.rb @@ -3,6 +3,8 @@ module API module Entities class Branch < Grape::Entity + include Gitlab::Routing + expose :name expose :commit, using: Entities::Commit do |repo_branch, options| @@ -36,6 +38,10 @@ module API expose :default do |repo_branch, options| options[:project].default_branch == repo_branch.name end + + expose :web_url do |repo_branch| + project_tree_url(options[:project], repo_branch.name) + end end end end diff --git a/lib/gitlab/alert_management/alert_params.rb b/lib/gitlab/alert_management/alert_params.rb new file mode 100644 index 00000000000..014eba6326d --- /dev/null +++ b/lib/gitlab/alert_management/alert_params.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module AlertManagement + class AlertParams + def self.from_generic_alert(project:, payload:) + parsed_payload = Gitlab::Alerting::NotificationPayloadParser.call(payload).with_indifferent_access + annotations = parsed_payload[:annotations] + + { + project_id: project.id, + title: annotations[:title], + description: annotations[:description], + monitoring_tool: annotations[:monitoring_tool], + service: annotations[:service], + hosts: Array(annotations[:hosts]), + payload: payload, + started_at: parsed_payload['startsAt'] + } + end + end + end +end diff --git a/spec/fixtures/api/schemas/public_api/v4/branch.json b/spec/fixtures/api/schemas/public_api/v4/branch.json index 3b0f010bc4f..0073a6d89fc 100644 --- a/spec/fixtures/api/schemas/public_api/v4/branch.json +++ b/spec/fixtures/api/schemas/public_api/v4/branch.json @@ -7,7 +7,8 @@ "protected", "default", "developers_can_push", - "developers_can_merge" + "developers_can_merge", + "web_url" ], "properties" : { "name": { "type": "string" }, @@ -17,7 +18,8 @@ "default": { "type": "boolean" }, "developers_can_push": { "type": "boolean" }, "developers_can_merge": { "type": "boolean" }, - "can_push": { "type": "boolean" } + "can_push": { "type": "boolean" }, + "web_url": { "type": "uri" } }, "additionalProperties": false } diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js new file mode 100644 index 00000000000..6b2a814d721 --- /dev/null +++ b/spec/frontend/groups/components/app_spec.js @@ -0,0 +1,507 @@ +import '~/flash'; +import $ from 'jquery'; +import Vue from 'vue'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import appComponent from '~/groups/components/app.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import eventHub from '~/groups/event_hub'; +import GroupsStore from '~/groups/store/groups_store'; +import GroupsService from '~/groups/service/groups_service'; +import * as urlUtilities from '~/lib/utils/url_utility'; + +import { + mockEndpoint, + mockGroups, + mockSearchedGroups, + mockRawPageInfo, + mockParentGroupItem, + mockRawChildren, + mockChildren, + mockPageInfo, +} from '../mock_data'; + +const createComponent = (hideProjects = false) => { + const Component = Vue.extend(appComponent); + const store = new GroupsStore(false); + const service = new GroupsService(mockEndpoint); + + store.state.pageInfo = mockPageInfo; + + return new Component({ + propsData: { + store, + service, + hideProjects, + }, + }); +}; + +describe('AppComponent', () => { + let vm; + let mock; + let getGroupsSpy; + + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + mock.onGet('/dashboard/groups.json').reply(200, mockGroups); + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + getGroupsSpy = jest.spyOn(vm.service, 'getGroups'); + return vm.$nextTick(); + }); + + describe('computed', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('groups', () => { + it('should return list of groups from store', () => { + jest.spyOn(vm.store, 'getGroups').mockImplementation(() => {}); + + const { groups } = vm; + + expect(vm.store.getGroups).toHaveBeenCalled(); + expect(groups).not.toBeDefined(); + }); + }); + + describe('pageInfo', () => { + it('should return pagination info from store', () => { + jest.spyOn(vm.store, 'getPaginationInfo').mockImplementation(() => {}); + + const { pageInfo } = vm; + + expect(vm.store.getPaginationInfo).toHaveBeenCalled(); + expect(pageInfo).not.toBeDefined(); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('fetchGroups', () => { + it('should call `getGroups` with all the params provided', () => { + return vm + .fetchGroups({ + parentId: 1, + page: 2, + filterGroupsBy: 'git', + sortBy: 'created_desc', + archived: true, + }) + .then(() => { + expect(getGroupsSpy).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true); + }); + }); + + it('should set headers to store for building pagination info when called with `updatePagination`', () => { + mock.onGet('/dashboard/groups.json').reply(200, { headers: mockRawPageInfo }); + + jest.spyOn(vm, 'updatePagination').mockImplementation(() => {}); + + return vm.fetchGroups({ updatePagination: true }).then(() => { + expect(getGroupsSpy).toHaveBeenCalled(); + expect(vm.updatePagination).toHaveBeenCalled(); + }); + }); + + it('should show flash error when request fails', () => { + mock.onGet('/dashboard/groups.json').reply(400); + + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + + return vm.fetchGroups({}).then(() => { + expect(vm.isLoading).toBe(false); + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.'); + }); + }); + }); + + describe('fetchAllGroups', () => { + beforeEach(() => { + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm, 'updateGroups'); + }); + + it('should fetch default set of groups', () => { + jest.spyOn(vm, 'updatePagination'); + + const fetchPromise = vm.fetchAllGroups(); + + expect(vm.isLoading).toBe(true); + + return fetchPromise.then(() => { + expect(vm.isLoading).toBe(false); + expect(vm.updateGroups).toHaveBeenCalled(); + }); + }); + + it('should fetch matching set of groups when app is loaded with search query', () => { + mock.onGet('/dashboard/groups.json').reply(200, mockSearchedGroups); + + const fetchPromise = vm.fetchAllGroups(); + + expect(vm.fetchGroups).toHaveBeenCalledWith({ + page: null, + filterGroupsBy: null, + sortBy: null, + updatePagination: true, + archived: null, + }); + return fetchPromise.then(() => { + expect(vm.updateGroups).toHaveBeenCalled(); + }); + }); + }); + + describe('fetchPage', () => { + beforeEach(() => { + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm, 'updateGroups'); + }); + + it('should fetch groups for provided page details and update window state', () => { + jest.spyOn(urlUtilities, 'mergeUrlParams'); + jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); + + const fetchPagePromise = vm.fetchPage(2, null, null, true); + + expect(vm.isLoading).toBe(true); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + page: 2, + filterGroupsBy: null, + sortBy: null, + updatePagination: true, + archived: true, + }); + + return fetchPagePromise.then(() => { + expect(vm.isLoading).toBe(false); + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(urlUtilities.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, expect.any(String)); + expect(window.history.replaceState).toHaveBeenCalledWith( + { + page: expect.any(String), + }, + expect.any(String), + expect.any(String), + ); + + expect(vm.updateGroups).toHaveBeenCalled(); + }); + }); + }); + + describe('toggleChildren', () => { + let groupItem; + + beforeEach(() => { + groupItem = Object.assign({}, mockParentGroupItem); + groupItem.isOpen = false; + groupItem.isChildrenLoading = false; + }); + + it('should fetch children of given group and expand it if group is collapsed and children are not loaded', () => { + mock.onGet('/dashboard/groups.json').reply(200, mockRawChildren); + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm.store, 'setGroupChildren').mockImplementation(() => {}); + + vm.toggleChildren(groupItem); + + expect(groupItem.isChildrenLoading).toBe(true); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + parentId: groupItem.id, + }); + return waitForPromises().then(() => { + expect(vm.store.setGroupChildren).toHaveBeenCalled(); + }); + }); + + it('should skip network request while expanding group if children are already loaded', () => { + jest.spyOn(vm, 'fetchGroups'); + groupItem.children = mockRawChildren; + + vm.toggleChildren(groupItem); + + expect(vm.fetchGroups).not.toHaveBeenCalled(); + expect(groupItem.isOpen).toBe(true); + }); + + it('should collapse group if it is already expanded', () => { + jest.spyOn(vm, 'fetchGroups'); + groupItem.isOpen = true; + + vm.toggleChildren(groupItem); + + expect(vm.fetchGroups).not.toHaveBeenCalled(); + expect(groupItem.isOpen).toBe(false); + }); + + it('should set `isChildrenLoading` back to `false` if load request fails', () => { + mock.onGet('/dashboard/groups.json').reply(400); + + vm.toggleChildren(groupItem); + + expect(groupItem.isChildrenLoading).toBe(true); + return waitForPromises().then(() => { + expect(groupItem.isChildrenLoading).toBe(false); + }); + }); + }); + + describe('showLeaveGroupModal', () => { + it('caches candidate group (as props) which is to be left', () => { + const group = Object.assign({}, mockParentGroupItem); + + expect(vm.targetGroup).toBe(null); + expect(vm.targetParentGroup).toBe(null); + vm.showLeaveGroupModal(group, mockParentGroupItem); + + expect(vm.targetGroup).not.toBe(null); + expect(vm.targetParentGroup).not.toBe(null); + }); + + it('updates props which show modal confirmation dialog', () => { + const group = Object.assign({}, mockParentGroupItem); + + expect(vm.showModal).toBe(false); + expect(vm.groupLeaveConfirmationMessage).toBe(''); + vm.showLeaveGroupModal(group, mockParentGroupItem); + + expect(vm.showModal).toBe(true); + expect(vm.groupLeaveConfirmationMessage).toBe( + `Are you sure you want to leave the "${group.fullName}" group?`, + ); + }); + }); + + describe('hideLeaveGroupModal', () => { + it('hides modal confirmation which is shown before leaving the group', () => { + const group = Object.assign({}, mockParentGroupItem); + vm.showLeaveGroupModal(group, mockParentGroupItem); + + expect(vm.showModal).toBe(true); + vm.hideLeaveGroupModal(); + + expect(vm.showModal).toBe(false); + }); + }); + + describe('leaveGroup', () => { + let groupItem; + let childGroupItem; + + beforeEach(() => { + groupItem = Object.assign({}, mockParentGroupItem); + groupItem.children = mockChildren; + [childGroupItem] = groupItem.children; + groupItem.isChildrenLoading = false; + vm.targetGroup = childGroupItem; + vm.targetParentGroup = groupItem; + }); + + it('hides modal confirmation leave group and remove group item from tree', () => { + const notice = `You left the "${childGroupItem.fullName}" group.`; + jest.spyOn(vm.service, 'leaveGroup').mockResolvedValue({ data: { notice } }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); + + vm.leaveGroup(); + + expect(vm.showModal).toBe(false); + expect(vm.targetGroup.isBeingRemoved).toBe(true); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath); + return waitForPromises().then(() => { + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup); + expect(window.Flash).toHaveBeenCalledWith(notice, 'notice'); + }); + }); + + it('should show error flash message if request failed to leave group', () => { + const message = 'An error occurred. Please try again.'; + jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 500 }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + + vm.leaveGroup(); + + expect(vm.targetGroup.isBeingRemoved).toBe(true); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + return waitForPromises().then(() => { + expect(vm.store.removeGroup).not.toHaveBeenCalled(); + expect(window.Flash).toHaveBeenCalledWith(message); + expect(vm.targetGroup.isBeingRemoved).toBe(false); + }); + }); + + it('should show appropriate error flash message if request forbids to leave group', () => { + const message = 'Failed to leave the group. Please make sure you are not the only owner.'; + jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 403 }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + + vm.leaveGroup(childGroupItem, groupItem); + + expect(vm.targetGroup.isBeingRemoved).toBe(true); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + return waitForPromises().then(() => { + expect(vm.store.removeGroup).not.toHaveBeenCalled(); + expect(window.Flash).toHaveBeenCalledWith(message); + expect(vm.targetGroup.isBeingRemoved).toBe(false); + }); + }); + }); + + describe('updatePagination', () => { + it('should set pagination info to store from provided headers', () => { + jest.spyOn(vm.store, 'setPaginationInfo').mockImplementation(() => {}); + + vm.updatePagination(mockRawPageInfo); + + expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo); + }); + }); + + describe('updateGroups', () => { + it('should call setGroups on store if method was called directly', () => { + jest.spyOn(vm.store, 'setGroups').mockImplementation(() => {}); + + vm.updateGroups(mockGroups); + + expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups); + }); + + it('should call setSearchedGroups on store if method was called with fromSearch param', () => { + jest.spyOn(vm.store, 'setSearchedGroups').mockImplementation(() => {}); + + vm.updateGroups(mockGroups, true); + + expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups); + }); + + it('should set `isSearchEmpty` prop based on groups count', () => { + vm.updateGroups(mockGroups); + + expect(vm.isSearchEmpty).toBe(false); + + vm.updateGroups([]); + + expect(vm.isSearchEmpty).toBe(true); + }); + }); + }); + + describe('created', () => { + it('should bind event listeners on eventHub', () => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); + + const newVm = createComponent(); + newVm.$mount(); + + return vm.$nextTick().then(() => { + expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function)); + newVm.$destroy(); + }); + }); + + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', () => { + const newVm = createComponent(); + newVm.$mount(); + return vm.$nextTick().then(() => { + expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search'); + newVm.$destroy(); + }); + }); + + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', () => { + const newVm = createComponent(true); + newVm.$mount(); + return vm.$nextTick().then(() => { + expect(newVm.searchEmptyMessage).toBe('No groups matched your search'); + newVm.$destroy(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', () => { + jest.spyOn(eventHub, '$off').mockImplementation(() => {}); + + const newVm = createComponent(); + newVm.$mount(); + newVm.$destroy(); + + return vm.$nextTick().then(() => { + expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', expect.any(Function)); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render loading icon', () => { + vm.isLoading = true; + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); + expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups'); + }); + }); + + it('should render groups tree', () => { + vm.store.state.groups = [mockParentGroupItem]; + vm.isLoading = false; + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); + }); + }); + + it('renders modal confirmation dialog', () => { + vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?'; + vm.showModal = true; + return vm.$nextTick().then(() => { + const modalDialogEl = vm.$el.querySelector('.modal'); + + expect(modalDialogEl).not.toBe(null); + expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?'); + expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave'); + }); + }); + }); +}); diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js new file mode 100644 index 00000000000..4b545f05c58 --- /dev/null +++ b/spec/frontend/groups/components/group_folder_spec.js @@ -0,0 +1,65 @@ +import Vue from 'vue'; + +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import { mockGroups, mockParentGroupItem } from '../mock_data'; + +const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => { + const Component = Vue.extend(groupFolderComponent); + + return new Component({ + propsData: { + groups, + parentGroup, + }, + }); +}; + +describe('GroupFolderComponent', () => { + let vm; + + beforeEach(() => { + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + vm.$mount(); + + return Vue.nextTick(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('hasMoreChildren', () => { + it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => { + expect(vm.hasMoreChildren).toBeFalsy(); + }); + }); + + describe('moreChildrenStats', () => { + it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => { + expect(vm.moreChildrenStats).toBe('3 more items'); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy(); + expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7); + }); + + it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => { + const parentGroup = Object.assign({}, mockParentGroupItem); + parentGroup.childrenCount = 21; + + const newVm = createComponent(mockGroups, parentGroup); + newVm.$mount(); + + expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined(); + newVm.$destroy(); + }); + }); +}); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js new file mode 100644 index 00000000000..d1f7653923a --- /dev/null +++ b/spec/frontend/groups/components/group_item_spec.js @@ -0,0 +1,215 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import eventHub from '~/groups/event_hub'; +import * as urlUtilities from '~/lib/utils/url_utility'; +import { mockParentGroupItem, mockChildren } from '../mock_data'; + +const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { + const Component = Vue.extend(groupItemComponent); + + return mountComponent(Component, { + group, + parentGroup, + }); +}; + +describe('GroupItemComponent', () => { + let vm; + + beforeEach(() => { + Vue.component('group-folder', groupFolderComponent); + + vm = createComponent(); + + return Vue.nextTick(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('groupDomId', () => { + it('should return ID string suffixed with group ID', () => { + expect(vm.groupDomId).toBe('group-55'); + }); + }); + + describe('rowClass', () => { + it('should return map of classes based on group details', () => { + const classes = ['is-open', 'has-children', 'has-description', 'being-removed']; + const { rowClass } = vm; + + expect(Object.keys(rowClass).length).toBe(classes.length); + Object.keys(rowClass).forEach(className => { + expect(classes.indexOf(className)).toBeGreaterThan(-1); + }); + }); + }); + + describe('hasChildren', () => { + it('should return boolean value representing if group has any children present', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.childrenCount = 5; + newVm = createComponent(group); + + expect(newVm.hasChildren).toBeTruthy(); + newVm.$destroy(); + + group.childrenCount = 0; + newVm = createComponent(group); + + expect(newVm.hasChildren).toBeFalsy(); + newVm.$destroy(); + }); + }); + + describe('hasAvatar', () => { + it('should return boolean value representing if group has any avatar present', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.avatarUrl = null; + newVm = createComponent(group); + + expect(newVm.hasAvatar).toBeFalsy(); + newVm.$destroy(); + + group.avatarUrl = '/uploads/group_avatar.png'; + newVm = createComponent(group); + + expect(newVm.hasAvatar).toBeTruthy(); + newVm.$destroy(); + }); + }); + + describe('isGroup', () => { + it('should return boolean value representing if group item is of type `group` or not', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.type = 'group'; + newVm = createComponent(group); + + expect(newVm.isGroup).toBeTruthy(); + newVm.$destroy(); + + group.type = 'project'; + newVm = createComponent(group); + + expect(newVm.isGroup).toBeFalsy(); + newVm.$destroy(); + }); + }); + }); + + describe('methods', () => { + describe('onClickRowGroup', () => { + let event; + + beforeEach(() => { + const classList = { + contains() { + return false; + }, + }; + + event = { + target: { + classList, + parentElement: { + classList, + }, + }, + }; + }); + + it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + vm.onClickRowGroup(event); + + expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group); + }); + + it('should navigate page to group homepage if group does not have any children present', () => { + jest.spyOn(urlUtilities, 'visitUrl').mockImplementation(); + const group = Object.assign({}, mockParentGroupItem); + group.childrenCount = 0; + const newVm = createComponent(group); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + newVm.onClickRowGroup(event); + + expect(eventHub.$emit).not.toHaveBeenCalled(); + expect(urlUtilities.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath); + }); + }); + }); + + describe('template', () => { + let group = null; + + describe('for a group pending deletion', () => { + beforeEach(() => { + group = { ...mockParentGroupItem, pendingRemoval: true }; + vm = createComponent(group); + }); + + it('renders the group pending removal badge', () => { + const badgeEl = vm.$el.querySelector('.badge-warning'); + + expect(badgeEl).toBeDefined(); + expect(badgeEl.innerHTML).toContain('pending removal'); + }); + }); + + describe('for a group not scheduled for deletion', () => { + beforeEach(() => { + group = { ...mockParentGroupItem, pendingRemoval: false }; + vm = createComponent(group); + }); + + it('does not render the group pending removal badge', () => { + const groupTextContainer = vm.$el.querySelector('.group-text-container'); + + expect(groupTextContainer).not.toContain('pending removal'); + }); + }); + + it('should render component template correctly', () => { + const visibilityIconEl = vm.$el.querySelector('.item-visibility'); + + expect(vm.$el.getAttribute('id')).toBe('group-55'); + expect(vm.$el.classList.contains('group-row')).toBeTruthy(); + + expect(vm.$el.querySelector('.group-row-contents')).toBeDefined(); + expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined(); + expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined(); + + expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined(); + expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined(); + expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined(); + + expect(vm.$el.querySelector('.avatar-container')).toBeDefined(); + expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined(); + expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined(); + + expect(vm.$el.querySelector('.title')).toBeDefined(); + expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined(); + + expect(visibilityIconEl).not.toBe(null); + expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip); + expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); + + expect(vm.$el.querySelector('.access-type')).toBeDefined(); + expect(vm.$el.querySelector('.description')).toBeDefined(); + + expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); + }); + }); +}); diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js new file mode 100644 index 00000000000..6205400eb03 --- /dev/null +++ b/spec/frontend/groups/components/groups_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import groupsComponent from '~/groups/components/groups.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import eventHub from '~/groups/event_hub'; +import { mockGroups, mockPageInfo } from '../mock_data'; + +const createComponent = (searchEmpty = false) => { + const Component = Vue.extend(groupsComponent); + + return mountComponent(Component, { + groups: mockGroups, + pageInfo: mockPageInfo, + searchEmptyMessage: 'No matching results', + searchEmpty, + }); +}; + +describe('GroupsComponent', () => { + let vm; + + beforeEach(() => { + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + + return vm.$nextTick(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('change', () => { + it('should emit `fetchPage` event when page is changed via pagination', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + + vm.change(2); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'fetchPage', + 2, + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); + expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); + expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); + expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0); + }); + }); + + it('should render empty search message when `searchEmpty` is `true`', () => { + vm.searchEmpty = true; + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined(); + }); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js new file mode 100644 index 00000000000..2e0738bd1b4 --- /dev/null +++ b/spec/frontend/groups/components/item_actions_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemActionsComponent from '~/groups/components/item_actions.vue'; +import eventHub from '~/groups/event_hub'; +import { mockParentGroupItem, mockChildren } from '../mock_data'; + +const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { + const Component = Vue.extend(itemActionsComponent); + + return mountComponent(Component, { + group, + parentGroup, + }); +}; + +describe('ItemActionsComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('onLeaveGroup', () => { + it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + vm.onLeaveGroup(); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'showLeaveGroupModal', + vm.group, + vm.parentGroup, + ); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.classList.contains('controls')).toBeTruthy(); + }); + + it('should render Edit Group button with correct attribute values', () => { + const group = Object.assign({}, mockParentGroupItem); + group.canEdit = true; + const newVm = createComponent(group); + + const editBtn = newVm.$el.querySelector('a.edit-group'); + + expect(editBtn).toBeDefined(); + expect(editBtn.classList.contains('no-expand')).toBeTruthy(); + expect(editBtn.getAttribute('href')).toBe(group.editPath); + expect(editBtn.getAttribute('aria-label')).toBe('Edit group'); + expect(editBtn.dataset.originalTitle).toBe('Edit group'); + expect(editBtn.querySelectorAll('svg use').length).not.toBe(0); + expect(editBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#settings'); + + newVm.$destroy(); + }); + + it('should render Leave Group button with correct attribute values', () => { + const group = Object.assign({}, mockParentGroupItem); + group.canLeave = true; + const newVm = createComponent(group); + + const leaveBtn = newVm.$el.querySelector('a.leave-group'); + + expect(leaveBtn).toBeDefined(); + expect(leaveBtn.classList.contains('no-expand')).toBeTruthy(); + expect(leaveBtn.getAttribute('href')).toBe(group.leavePath); + expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group'); + expect(leaveBtn.dataset.originalTitle).toBe('Leave this group'); + expect(leaveBtn.querySelectorAll('svg use').length).not.toBe(0); + expect(leaveBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#leave'); + + newVm.$destroy(); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js new file mode 100644 index 00000000000..bfe27be9b51 --- /dev/null +++ b/spec/frontend/groups/components/item_caret_spec.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemCaretComponent from '~/groups/components/item_caret.vue'; + +const createComponent = (isGroupOpen = false) => { + const Component = Vue.extend(itemCaretComponent); + + return mountComponent(Component, { + isGroupOpen, + }); +}; + +describe('ItemCaretComponent', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('should render component template correctly', () => { + vm = createComponent(); + expect(vm.$el.classList.contains('folder-caret')).toBeTruthy(); + expect(vm.$el.querySelectorAll('svg').length).toBe(1); + }); + + it('should render caret down icon if `isGroupOpen` prop is `true`', () => { + vm = createComponent(true); + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down'); + }); + + it('should render caret right icon if `isGroupOpen` prop is `false`', () => { + vm = createComponent(); + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right'); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js new file mode 100644 index 00000000000..fb4285a2b04 --- /dev/null +++ b/spec/frontend/groups/components/item_stats_spec.js @@ -0,0 +1,128 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemStatsComponent from '~/groups/components/item_stats.vue'; +import { + mockParentGroupItem, + ITEM_TYPE, + VISIBILITY_TYPE_ICON, + GROUP_VISIBILITY_TYPE, + PROJECT_VISIBILITY_TYPE, +} from '../mock_data'; + +const createComponent = (item = mockParentGroupItem) => { + const Component = Vue.extend(itemStatsComponent); + + return mountComponent(Component, { + item, + }); +}; + +describe('ItemStatsComponent', () => { + describe('computed', () => { + describe('visibilityIcon', () => { + it('should return icon class based on `item.visibility` value', () => { + Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => { + const item = Object.assign({}, mockParentGroupItem, { visibility }); + const vm = createComponent(item); + + expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]); + vm.$destroy(); + }); + }); + }); + + describe('visibilityTooltip', () => { + it('should return tooltip string for Group based on `item.visibility` value', () => { + Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => { + const item = Object.assign({}, mockParentGroupItem, { + visibility, + type: ITEM_TYPE.GROUP, + }); + const vm = createComponent(item); + + expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]); + vm.$destroy(); + }); + }); + + it('should return tooltip string for Project based on `item.visibility` value', () => { + Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => { + const item = Object.assign({}, mockParentGroupItem, { + visibility, + type: ITEM_TYPE.PROJECT, + }); + const vm = createComponent(item); + + expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]); + vm.$destroy(); + }); + }); + }); + + describe('isProject', () => { + it('should return boolean value representing whether `item.type` is Project or not', () => { + let item; + let vm; + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT }); + vm = createComponent(item); + + expect(vm.isProject).toBeTruthy(); + vm.$destroy(); + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); + vm = createComponent(item); + + expect(vm.isProject).toBeFalsy(); + vm.$destroy(); + }); + }); + + describe('isGroup', () => { + it('should return boolean value representing whether `item.type` is Group or not', () => { + let item; + let vm; + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); + vm = createComponent(item); + + expect(vm.isGroup).toBeTruthy(); + vm.$destroy(); + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT }); + vm = createComponent(item); + + expect(vm.isGroup).toBeFalsy(); + vm.$destroy(); + }); + }); + }); + + describe('template', () => { + it('renders component container element correctly', () => { + const vm = createComponent(); + + expect(vm.$el.classList.contains('stats')).toBeTruthy(); + + vm.$destroy(); + }); + + it('renders start count and last updated information for project item correctly', () => { + const item = Object.assign({}, mockParentGroupItem, { + type: ITEM_TYPE.PROJECT, + starCount: 4, + }); + const vm = createComponent(item); + + const projectStarIconEl = vm.$el.querySelector('.project-stars'); + + expect(projectStarIconEl).not.toBeNull(); + expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); + expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0); + expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0); + + vm.$destroy(); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js new file mode 100644 index 00000000000..9561a329887 --- /dev/null +++ b/spec/frontend/groups/components/item_stats_value_spec.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemStatsValueComponent from '~/groups/components/item_stats_value.vue'; + +const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => { + const Component = Vue.extend(itemStatsValueComponent); + + return mountComponent(Component, { + title, + cssClass, + iconName, + tooltipPlacement, + value, + }); +}; + +describe('ItemStatsValueComponent', () => { + describe('computed', () => { + let vm; + const itemConfig = { + title: 'Subgroups', + cssClass: 'number-subgroups', + iconName: 'folder', + tooltipPlacement: 'left', + }; + + describe('isValuePresent', () => { + it('returns true if non-empty `value` is present', () => { + vm = createComponent(Object.assign({}, itemConfig, { value: 10 })); + + expect(vm.isValuePresent).toBeTruthy(); + }); + + it('returns false if empty `value` is present', () => { + vm = createComponent(itemConfig); + + expect(vm.isValuePresent).toBeFalsy(); + }); + + afterEach(() => { + vm.$destroy(); + }); + }); + }); + + describe('template', () => { + let vm; + beforeEach(() => { + vm = createComponent({ + title: 'Subgroups', + cssClass: 'number-subgroups', + iconName: 'folder', + tooltipPlacement: 'left', + value: 10, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders component element correctly', () => { + expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy(); + expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0); + expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0); + }); + + it('renders element tooltip correctly', () => { + expect(vm.$el.dataset.originalTitle).toBe('Subgroups'); + expect(vm.$el.dataset.placement).toBe('left'); + }); + + it('renders element icon correctly', () => { + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder'); + }); + + it('renders value count correctly', () => { + expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10'); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js new file mode 100644 index 00000000000..251b5b5ff4c --- /dev/null +++ b/spec/frontend/groups/components/item_type_icon_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import itemTypeIconComponent from '~/groups/components/item_type_icon.vue'; +import { ITEM_TYPE } from '../mock_data'; + +const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => { + const Component = Vue.extend(itemTypeIconComponent); + + return mountComponent(Component, { + itemType, + isGroupOpen, + }); +}; + +describe('ItemTypeIconComponent', () => { + describe('template', () => { + it('should render component template correctly', () => { + const vm = createComponent(); + + expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy(); + vm.$destroy(); + }); + + it('should render folder open or close icon based `isGroupOpen` prop value', () => { + let vm; + + vm = createComponent(ITEM_TYPE.GROUP, true); + + expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open'); + vm.$destroy(); + + vm = createComponent(ITEM_TYPE.GROUP); + + expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder'); + vm.$destroy(); + }); + + it('should render bookmark icon based on `isProject` prop value', () => { + let vm; + + vm = createComponent(ITEM_TYPE.PROJECT); + + expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark'); + vm.$destroy(); + + vm = createComponent(ITEM_TYPE.GROUP); + + expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark'); + vm.$destroy(); + }); + }); +}); diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js new file mode 100644 index 00000000000..380dda9f7b1 --- /dev/null +++ b/spec/frontend/groups/mock_data.js @@ -0,0 +1,398 @@ +export const mockEndpoint = '/dashboard/groups.json'; + +export const ITEM_TYPE = { + PROJECT: 'project', + GROUP: 'group', +}; + +export const GROUP_VISIBILITY_TYPE = { + public: 'Public - The group and any public projects can be viewed without any authentication.', + internal: 'Internal - The group and any internal projects can be viewed by any logged in user.', + private: 'Private - The group and its projects can only be viewed by members.', +}; + +export const PROJECT_VISIBILITY_TYPE = { + public: 'Public - The project can be accessed without any authentication.', + internal: 'Internal - The project can be accessed by any logged in user.', + private: + 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', +}; + +export const VISIBILITY_TYPE_ICON = { + public: 'earth', + internal: 'shield', + private: 'lock', +}; + +export const mockParentGroupItem = { + id: 55, + name: 'hardware', + description: '', + visibility: 'public', + fullName: 'platform / hardware', + relativePath: '/platform/hardware', + canEdit: true, + type: 'group', + avatarUrl: null, + permission: 'Owner', + editPath: '/groups/platform/hardware/edit', + childrenCount: 3, + leavePath: '/groups/platform/hardware/group_members/leave', + parentId: 54, + memberCount: '1', + projectCount: 1, + subgroupCount: 2, + canLeave: false, + children: [], + isOpen: true, + isChildrenLoading: false, + isBeingRemoved: false, + updatedAt: '2017-04-09T18:40:39.101Z', +}; + +export const mockRawChildren = [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp', + relative_path: '/platform/hardware/bsp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/edit', + children_count: 6, + leave_path: '/groups/platform/hardware/bsp/group_members/leave', + parent_id: 55, + number_users_with_delimiter: '1', + project_count: 4, + subgroup_count: 2, + can_leave: false, + children: [], + updated_at: '2017-04-09T18:40:39.101Z', + }, +]; + +export const mockChildren = [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + fullName: 'platform / hardware / bsp', + relativePath: '/platform/hardware/bsp', + canEdit: true, + type: 'group', + avatarUrl: null, + permission: 'Owner', + editPath: '/groups/platform/hardware/bsp/edit', + childrenCount: 6, + leavePath: '/groups/platform/hardware/bsp/group_members/leave', + parentId: 55, + memberCount: '1', + projectCount: 4, + subgroupCount: 2, + canLeave: false, + children: [], + isOpen: true, + isChildrenLoading: false, + isBeingRemoved: false, + updatedAt: '2017-04-09T18:40:39.101Z', + }, +]; + +export const mockGroups = [ + { + id: 75, + name: 'test-group', + description: '', + visibility: 'public', + full_name: 'test-group', + relative_path: '/test-group', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/test-group/edit', + children_count: 2, + leave_path: '/groups/test-group/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 2, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 67, + name: 'open-source', + description: '', + visibility: 'private', + full_name: 'open-source', + relative_path: '/open-source', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/open-source/edit', + children_count: 0, + leave_path: '/groups/open-source/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 54, + name: 'platform', + description: '', + visibility: 'public', + full_name: 'platform', + relative_path: '/platform', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/edit', + children_count: 1, + leave_path: '/groups/platform/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 1, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 5, + name: 'H5bp', + description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.', + visibility: 'public', + full_name: 'H5bp', + relative_path: '/h5bp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/h5bp/edit', + children_count: 1, + leave_path: '/groups/h5bp/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 1, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 4, + name: 'Twitter', + description: 'Deserunt hic nostrum placeat veniam.', + visibility: 'public', + full_name: 'Twitter', + relative_path: '/twitter', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/twitter/edit', + children_count: 2, + leave_path: '/groups/twitter/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 2, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 3, + name: 'Documentcloud', + description: 'Consequatur saepe totam ea pariatur maxime.', + visibility: 'public', + full_name: 'Documentcloud', + relative_path: '/documentcloud', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/documentcloud/edit', + children_count: 1, + leave_path: '/groups/documentcloud/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 1, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, + { + id: 2, + name: 'Gitlab Org', + description: 'Debitis ea quas aperiam velit doloremque ab.', + visibility: 'public', + full_name: 'Gitlab Org', + relative_path: '/gitlab-org', + can_edit: true, + type: 'group', + avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', + permission: 'Owner', + edit_path: '/groups/gitlab-org/edit', + children_count: 4, + leave_path: '/groups/gitlab-org/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 4, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + }, +]; + +export const mockSearchedGroups = [ + { + id: 55, + name: 'hardware', + description: '', + visibility: 'public', + full_name: 'platform / hardware', + relative_path: '/platform/hardware', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/edit', + children_count: 3, + leave_path: '/groups/platform/hardware/group_members/leave', + parent_id: 54, + number_users_with_delimiter: '1', + project_count: 1, + subgroup_count: 2, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + children: [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp', + relative_path: '/platform/hardware/bsp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/edit', + children_count: 6, + leave_path: '/groups/platform/hardware/bsp/group_members/leave', + parent_id: 55, + number_users_with_delimiter: '1', + project_count: 4, + subgroup_count: 2, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + children: [ + { + id: 60, + name: 'kernel', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel', + relative_path: '/platform/hardware/bsp/kernel', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/kernel/edit', + children_count: 1, + leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave', + parent_id: 57, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 1, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + children: [ + { + id: 61, + name: 'common', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common', + relative_path: '/platform/hardware/bsp/kernel/common', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/kernel/common/edit', + children_count: 2, + leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave', + parent_id: 60, + number_users_with_delimiter: '1', + project_count: 2, + subgroup_count: 0, + can_leave: false, + updated_at: '2017-04-09T18:40:39.101Z', + children: [ + { + id: 17, + name: 'v4.4', + description: + 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.4', + relative_path: '/platform/hardware/bsp/kernel/common/v4.4', + can_edit: true, + type: 'project', + avatar_url: null, + permission: null, + edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit', + star_count: 0, + updated_at: '2017-09-12T06:37:04.925Z', + }, + { + id: 16, + name: 'v4.1', + description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.1', + relative_path: '/platform/hardware/bsp/kernel/common/v4.1', + can_edit: true, + type: 'project', + avatar_url: null, + permission: null, + edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit', + star_count: 0, + updated_at: '2017-04-09T18:41:03.112Z', + }, + ], + }, + ], + }, + ], + }, + ], + }, +]; + +export const mockRawPageInfo = { + 'x-per-page': 10, + 'x-page': 10, + 'x-total': 10, + 'x-total-pages': 10, + 'x-next-page': 10, + 'x-prev-page': 10, +}; + +export const mockPageInfo = { + perPage: 10, + page: 10, + total: 10, + totalPages: 10, + nextPage: 10, + prevPage: 10, +}; diff --git a/spec/frontend/groups/service/groups_service_spec.js b/spec/frontend/groups/service/groups_service_spec.js new file mode 100644 index 00000000000..38a565eba01 --- /dev/null +++ b/spec/frontend/groups/service/groups_service_spec.js @@ -0,0 +1,42 @@ +import axios from '~/lib/utils/axios_utils'; + +import GroupsService from '~/groups/service/groups_service'; +import { mockEndpoint, mockParentGroupItem } from '../mock_data'; + +describe('GroupsService', () => { + let service; + + beforeEach(() => { + service = new GroupsService(mockEndpoint); + }); + + describe('getGroups', () => { + it('should return promise for `GET` request on provided endpoint', () => { + jest.spyOn(axios, 'get').mockResolvedValue(); + const params = { + page: 2, + filter: 'git', + sort: 'created_asc', + archived: true, + }; + + service.getGroups(55, 2, 'git', 'created_asc', true); + + expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params: { parent_id: 55 } }); + + service.getGroups(null, 2, 'git', 'created_asc', true); + + expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params }); + }); + }); + + describe('leaveGroup', () => { + it('should return promise for `DELETE` request on provided endpoint', () => { + jest.spyOn(axios, 'delete').mockResolvedValue(); + + service.leaveGroup(mockParentGroupItem.leavePath); + + expect(axios.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath); + }); + }); +}); diff --git a/spec/frontend/groups/store/groups_store_spec.js b/spec/frontend/groups/store/groups_store_spec.js new file mode 100644 index 00000000000..9eefcbe0275 --- /dev/null +++ b/spec/frontend/groups/store/groups_store_spec.js @@ -0,0 +1,123 @@ +import GroupsStore from '~/groups/store/groups_store'; +import { + mockGroups, + mockSearchedGroups, + mockParentGroupItem, + mockRawChildren, + mockRawPageInfo, +} from '../mock_data'; + +describe('ProjectsStore', () => { + describe('constructor', () => { + it('should initialize default state', () => { + let store; + + store = new GroupsStore(); + + expect(Object.keys(store.state).length).toBe(2); + expect(Array.isArray(store.state.groups)).toBeTruthy(); + expect(Object.keys(store.state.pageInfo).length).toBe(0); + expect(store.hideProjects).not.toBeDefined(); + + store = new GroupsStore(true); + + expect(store.hideProjects).toBeTruthy(); + }); + }); + + describe('setGroups', () => { + it('should set groups to state', () => { + const store = new GroupsStore(); + jest.spyOn(store, 'formatGroupItem'); + + store.setGroups(mockGroups); + + expect(store.state.groups.length).toBe(mockGroups.length); + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); + expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1); + }); + }); + + describe('setSearchedGroups', () => { + it('should set searched groups to state', () => { + const store = new GroupsStore(); + jest.spyOn(store, 'formatGroupItem'); + + store.setSearchedGroups(mockSearchedGroups); + + expect(store.state.groups.length).toBe(mockSearchedGroups.length); + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); + expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1); + expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName')).toBeGreaterThan( + -1, + ); + }); + }); + + describe('setGroupChildren', () => { + it('should set children to group item in state', () => { + const store = new GroupsStore(); + jest.spyOn(store, 'formatGroupItem'); + + store.setGroupChildren(mockParentGroupItem, mockRawChildren); + + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); + expect(mockParentGroupItem.children.length).toBe(1); + expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1); + expect(mockParentGroupItem.isOpen).toBeTruthy(); + expect(mockParentGroupItem.isChildrenLoading).toBeFalsy(); + }); + }); + + describe('setPaginationInfo', () => { + it('should parse and set pagination info in state', () => { + const store = new GroupsStore(); + + store.setPaginationInfo(mockRawPageInfo); + + expect(store.state.pageInfo.perPage).toBe(10); + expect(store.state.pageInfo.page).toBe(10); + expect(store.state.pageInfo.total).toBe(10); + expect(store.state.pageInfo.totalPages).toBe(10); + expect(store.state.pageInfo.nextPage).toBe(10); + expect(store.state.pageInfo.previousPage).toBe(10); + }); + }); + + describe('formatGroupItem', () => { + it('should parse group item object and return updated object', () => { + let store; + let updatedGroupItem; + + store = new GroupsStore(); + updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + + expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); + expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count); + expect(updatedGroupItem.isChildrenLoading).toBe(false); + expect(updatedGroupItem.isBeingRemoved).toBe(false); + + store = new GroupsStore(true); + updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + + expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); + expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count); + }); + }); + + describe('removeGroup', () => { + it('should remove children from group item in state', () => { + const store = new GroupsStore(); + const rawParentGroup = Object.assign({}, mockGroups[0]); + const rawChildGroup = Object.assign({}, mockGroups[1]); + + store.setGroups([rawParentGroup]); + store.setGroupChildren(store.state.groups[0], [rawChildGroup]); + const childItem = store.state.groups[0].children[0]; + + store.removeGroup(childItem, store.state.groups[0]); + + expect(store.state.groups[0].children.length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index cb2193e1d9a..800a7e586a8 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -26,7 +26,7 @@ function factory(propsData = {}) { }, }); - vm.setData({ ref: 'master' }); + vm.setData({ escapedRef: 'master' }); } describe('Repository table row component', () => { diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index 8da2f39f71f..5637d0be957 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -53,7 +53,7 @@ describe('fetchLogsTree', () => { client = { readQuery: () => ({ projectPath: 'gitlab-org/gitlab-foss', - ref: 'master', + escapedRef: 'master', commits: [], }), writeQuery: jest.fn(), @@ -86,16 +86,18 @@ describe('fetchLogsTree', () => { it('calls entry resolver', () => fetchLogsTree(client, '', '0', resolver).then(() => { - expect(resolver.resolve).toHaveBeenCalledWith({ - __typename: 'LogTreeCommit', - commitPath: 'https://test.com', - committedDate: '2019-01-01', - fileName: 'index.js', - filePath: '/index.js', - message: 'testing message', - sha: '123', - type: 'blob', - }); + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ + __typename: 'LogTreeCommit', + commitPath: 'https://test.com', + committedDate: '2019-01-01', + fileName: 'index.js', + filePath: '/index.js', + message: 'testing message', + sha: '123', + type: 'blob', + }), + ); })); it('writes query to client', () => @@ -104,7 +106,7 @@ describe('fetchLogsTree', () => { query: expect.anything(), data: { commits: [ - { + expect.objectContaining({ __typename: 'LogTreeCommit', commitPath: 'https://test.com', committedDate: '2019-01-01', @@ -113,7 +115,7 @@ describe('fetchLogsTree', () => { message: 'testing message', sha: '123', type: 'blob', - }, + }), ], }, }); diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js index 6944b23558a..f2f3dda41d9 100644 --- a/spec/frontend/repository/router_spec.js +++ b/spec/frontend/repository/router_spec.js @@ -4,13 +4,12 @@ import createRouter from '~/repository/router'; describe('Repository router spec', () => { it.each` - path | branch | component | componentName - ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'} - ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/feature/test-%23/app/assets'} | ${'feature/test-#'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'} + path | branch | component | componentName + ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'} + ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'} `('sets component as $componentName for path "$path"', ({ path, component, branch }) => { const router = createRouter('', branch); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js new file mode 100644 index 00000000000..cda5ca68d9b --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js @@ -0,0 +1,100 @@ +import { mount } from '@vue/test-utils'; +import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; +import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; +import { mockStore } from '../mock_data'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; + +describe('MrWidgetPipelineContainer', () => { + let wrapper; + let mock; + + const factory = (props = {}) => { + wrapper = mount(MrWidgetPipelineContainer, { + propsData: { + mr: Object.assign({}, mockStore), + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet().reply(200, {}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when pre merge', () => { + beforeEach(() => { + factory(); + }); + + it('renders pipeline', () => { + expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + pipeline: mockStore.pipeline, + pipelineCoverageDelta: mockStore.pipelineCoverageDelta, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.sourceBranch, + sourceBranchLink: mockStore.sourceBranchLink, + }); + }); + + it('renders deployments', () => { + const expectedProps = mockStore.deployments.map(dep => + expect.objectContaining({ + deployment: dep, + showMetrics: false, + }), + ); + + const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment'); + + expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps); + }); + }); + + describe('when post merge', () => { + beforeEach(() => { + factory({ + isPostMerge: true, + }); + }); + + it('renders pipeline', () => { + expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + pipeline: mockStore.mergePipeline, + pipelineCoverageDelta: mockStore.pipelineCoverageDelta, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.targetBranch, + sourceBranchLink: mockStore.targetBranch, + }); + }); + + it('renders deployments', () => { + const expectedProps = mockStore.postMergeDeployments.map(dep => + expect.objectContaining({ + deployment: dep, + showMetrics: true, + }), + ); + + const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment'); + + expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps); + }); + }); + + describe('with artifacts path', () => { + it('renders the artifacts app', () => { + expect(wrapper.find(ArtifactsApp).isVisible()).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js deleted file mode 100644 index 23b2564d3f9..00000000000 --- a/spec/javascripts/groups/components/app_spec.js +++ /dev/null @@ -1,533 +0,0 @@ -import '~/flash'; -import $ from 'jquery'; -import Vue from 'vue'; - -import appComponent from '~/groups/components/app.vue'; -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import eventHub from '~/groups/event_hub'; -import GroupsStore from '~/groups/store/groups_store'; -import GroupsService from '~/groups/service/groups_service'; - -import { - mockEndpoint, - mockGroups, - mockSearchedGroups, - mockRawPageInfo, - mockParentGroupItem, - mockRawChildren, - mockChildren, - mockPageInfo, -} from '../mock_data'; - -const createComponent = (hideProjects = false) => { - const Component = Vue.extend(appComponent); - const store = new GroupsStore(false); - const service = new GroupsService(mockEndpoint); - - store.state.pageInfo = mockPageInfo; - - return new Component({ - propsData: { - store, - service, - hideProjects, - }, - }); -}; - -const returnServicePromise = (data, failed) => - new Promise((resolve, reject) => { - if (failed) { - reject(data); - } else { - resolve({ - json() { - return data; - }, - }); - } - }); - -describe('AppComponent', () => { - let vm; - - beforeEach(done => { - Vue.component('group-folder', groupFolderComponent); - Vue.component('group-item', groupItemComponent); - - vm = createComponent(); - - Vue.nextTick(() => { - done(); - }); - }); - - describe('computed', () => { - beforeEach(() => { - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('groups', () => { - it('should return list of groups from store', () => { - spyOn(vm.store, 'getGroups'); - - const { groups } = vm; - - expect(vm.store.getGroups).toHaveBeenCalled(); - expect(groups).not.toBeDefined(); - }); - }); - - describe('pageInfo', () => { - it('should return pagination info from store', () => { - spyOn(vm.store, 'getPaginationInfo'); - - const { pageInfo } = vm; - - expect(vm.store.getPaginationInfo).toHaveBeenCalled(); - expect(pageInfo).not.toBeDefined(); - }); - }); - }); - - describe('methods', () => { - beforeEach(() => { - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('fetchGroups', () => { - it('should call `getGroups` with all the params provided', done => { - spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups)); - - vm.fetchGroups({ - parentId: 1, - page: 2, - filterGroupsBy: 'git', - sortBy: 'created_desc', - archived: true, - }); - setTimeout(() => { - expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true); - done(); - }, 0); - }); - - it('should set headers to store for building pagination info when called with `updatePagination`', done => { - spyOn(vm.service, 'getGroups').and.returnValue( - returnServicePromise({ headers: mockRawPageInfo }), - ); - spyOn(vm, 'updatePagination'); - - vm.fetchGroups({ updatePagination: true }); - setTimeout(() => { - expect(vm.service.getGroups).toHaveBeenCalled(); - expect(vm.updatePagination).toHaveBeenCalled(); - done(); - }, 0); - }); - - it('should show flash error when request fails', done => { - spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true)); - spyOn($, 'scrollTo'); - spyOn(window, 'Flash'); - - vm.fetchGroups({}); - setTimeout(() => { - expect(vm.isLoading).toBe(false); - expect($.scrollTo).toHaveBeenCalledWith(0); - expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.'); - done(); - }, 0); - }); - }); - - describe('fetchAllGroups', () => { - it('should fetch default set of groups', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups)); - spyOn(vm, 'updatePagination').and.callThrough(); - spyOn(vm, 'updateGroups').and.callThrough(); - - vm.fetchAllGroups(); - - expect(vm.isLoading).toBe(true); - expect(vm.fetchGroups).toHaveBeenCalled(); - setTimeout(() => { - expect(vm.isLoading).toBe(false); - expect(vm.updateGroups).toHaveBeenCalled(); - done(); - }, 0); - }); - - it('should fetch matching set of groups when app is loaded with search query', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups)); - spyOn(vm, 'updateGroups').and.callThrough(); - - vm.fetchAllGroups(); - - expect(vm.fetchGroups).toHaveBeenCalledWith({ - page: null, - filterGroupsBy: null, - sortBy: null, - updatePagination: true, - archived: null, - }); - setTimeout(() => { - expect(vm.updateGroups).toHaveBeenCalled(); - done(); - }, 0); - }); - }); - - describe('fetchPage', () => { - it('should fetch groups for provided page details and update window state', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups)); - spyOn(vm, 'updateGroups').and.callThrough(); - const mergeUrlParams = spyOnDependency(appComponent, 'mergeUrlParams').and.callThrough(); - spyOn(window.history, 'replaceState'); - spyOn($, 'scrollTo'); - - vm.fetchPage(2, null, null, true); - - expect(vm.isLoading).toBe(true); - expect(vm.fetchGroups).toHaveBeenCalledWith({ - page: 2, - filterGroupsBy: null, - sortBy: null, - updatePagination: true, - archived: true, - }); - setTimeout(() => { - expect(vm.isLoading).toBe(false); - expect($.scrollTo).toHaveBeenCalledWith(0); - expect(mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String)); - expect(window.history.replaceState).toHaveBeenCalledWith( - { - page: jasmine.any(String), - }, - jasmine.any(String), - jasmine.any(String), - ); - - expect(vm.updateGroups).toHaveBeenCalled(); - done(); - }, 0); - }); - }); - - describe('toggleChildren', () => { - let groupItem; - - beforeEach(() => { - groupItem = Object.assign({}, mockParentGroupItem); - groupItem.isOpen = false; - groupItem.isChildrenLoading = false; - }); - - it('should fetch children of given group and expand it if group is collapsed and children are not loaded', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren)); - spyOn(vm.store, 'setGroupChildren'); - - vm.toggleChildren(groupItem); - - expect(groupItem.isChildrenLoading).toBe(true); - expect(vm.fetchGroups).toHaveBeenCalledWith({ - parentId: groupItem.id, - }); - setTimeout(() => { - expect(vm.store.setGroupChildren).toHaveBeenCalled(); - done(); - }, 0); - }); - - it('should skip network request while expanding group if children are already loaded', () => { - spyOn(vm, 'fetchGroups'); - groupItem.children = mockRawChildren; - - vm.toggleChildren(groupItem); - - expect(vm.fetchGroups).not.toHaveBeenCalled(); - expect(groupItem.isOpen).toBe(true); - }); - - it('should collapse group if it is already expanded', () => { - spyOn(vm, 'fetchGroups'); - groupItem.isOpen = true; - - vm.toggleChildren(groupItem); - - expect(vm.fetchGroups).not.toHaveBeenCalled(); - expect(groupItem.isOpen).toBe(false); - }); - - it('should set `isChildrenLoading` back to `false` if load request fails', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true)); - - vm.toggleChildren(groupItem); - - expect(groupItem.isChildrenLoading).toBe(true); - setTimeout(() => { - expect(groupItem.isChildrenLoading).toBe(false); - done(); - }, 0); - }); - }); - - describe('showLeaveGroupModal', () => { - it('caches candidate group (as props) which is to be left', () => { - const group = Object.assign({}, mockParentGroupItem); - - expect(vm.targetGroup).toBe(null); - expect(vm.targetParentGroup).toBe(null); - vm.showLeaveGroupModal(group, mockParentGroupItem); - - expect(vm.targetGroup).not.toBe(null); - expect(vm.targetParentGroup).not.toBe(null); - }); - - it('updates props which show modal confirmation dialog', () => { - const group = Object.assign({}, mockParentGroupItem); - - expect(vm.showModal).toBe(false); - expect(vm.groupLeaveConfirmationMessage).toBe(''); - vm.showLeaveGroupModal(group, mockParentGroupItem); - - expect(vm.showModal).toBe(true); - expect(vm.groupLeaveConfirmationMessage).toBe( - `Are you sure you want to leave the "${group.fullName}" group?`, - ); - }); - }); - - describe('hideLeaveGroupModal', () => { - it('hides modal confirmation which is shown before leaving the group', () => { - const group = Object.assign({}, mockParentGroupItem); - vm.showLeaveGroupModal(group, mockParentGroupItem); - - expect(vm.showModal).toBe(true); - vm.hideLeaveGroupModal(); - - expect(vm.showModal).toBe(false); - }); - }); - - describe('leaveGroup', () => { - let groupItem; - let childGroupItem; - - beforeEach(() => { - groupItem = Object.assign({}, mockParentGroupItem); - groupItem.children = mockChildren; - [childGroupItem] = groupItem.children; - groupItem.isChildrenLoading = false; - vm.targetGroup = childGroupItem; - vm.targetParentGroup = groupItem; - }); - - it('hides modal confirmation leave group and remove group item from tree', done => { - const notice = `You left the "${childGroupItem.fullName}" group.`; - spyOn(vm.service, 'leaveGroup').and.returnValue(Promise.resolve({ data: { notice } })); - spyOn(vm.store, 'removeGroup').and.callThrough(); - spyOn(window, 'Flash'); - spyOn($, 'scrollTo'); - - vm.leaveGroup(); - - expect(vm.showModal).toBe(false); - expect(vm.targetGroup.isBeingRemoved).toBe(true); - expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath); - setTimeout(() => { - expect($.scrollTo).toHaveBeenCalledWith(0); - expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup); - expect(window.Flash).toHaveBeenCalledWith(notice, 'notice'); - done(); - }, 0); - }); - - it('should show error flash message if request failed to leave group', done => { - const message = 'An error occurred. Please try again.'; - spyOn(vm.service, 'leaveGroup').and.returnValue( - returnServicePromise({ status: 500 }, true), - ); - spyOn(vm.store, 'removeGroup').and.callThrough(); - spyOn(window, 'Flash'); - - vm.leaveGroup(); - - expect(vm.targetGroup.isBeingRemoved).toBe(true); - expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); - setTimeout(() => { - expect(vm.store.removeGroup).not.toHaveBeenCalled(); - expect(window.Flash).toHaveBeenCalledWith(message); - expect(vm.targetGroup.isBeingRemoved).toBe(false); - done(); - }, 0); - }); - - it('should show appropriate error flash message if request forbids to leave group', done => { - const message = 'Failed to leave the group. Please make sure you are not the only owner.'; - spyOn(vm.service, 'leaveGroup').and.returnValue( - returnServicePromise({ status: 403 }, true), - ); - spyOn(vm.store, 'removeGroup').and.callThrough(); - spyOn(window, 'Flash'); - - vm.leaveGroup(childGroupItem, groupItem); - - expect(vm.targetGroup.isBeingRemoved).toBe(true); - expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); - setTimeout(() => { - expect(vm.store.removeGroup).not.toHaveBeenCalled(); - expect(window.Flash).toHaveBeenCalledWith(message); - expect(vm.targetGroup.isBeingRemoved).toBe(false); - done(); - }, 0); - }); - }); - - describe('updatePagination', () => { - it('should set pagination info to store from provided headers', () => { - spyOn(vm.store, 'setPaginationInfo'); - - vm.updatePagination(mockRawPageInfo); - - expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo); - }); - }); - - describe('updateGroups', () => { - it('should call setGroups on store if method was called directly', () => { - spyOn(vm.store, 'setGroups'); - - vm.updateGroups(mockGroups); - - expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups); - }); - - it('should call setSearchedGroups on store if method was called with fromSearch param', () => { - spyOn(vm.store, 'setSearchedGroups'); - - vm.updateGroups(mockGroups, true); - - expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups); - }); - - it('should set `isSearchEmpty` prop based on groups count', () => { - vm.updateGroups(mockGroups); - - expect(vm.isSearchEmpty).toBe(false); - - vm.updateGroups([]); - - expect(vm.isSearchEmpty).toBe(true); - }); - }); - }); - - describe('created', () => { - it('should bind event listeners on eventHub', done => { - spyOn(eventHub, '$on'); - - const newVm = createComponent(); - newVm.$mount(); - - Vue.nextTick(() => { - expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function)); - newVm.$destroy(); - done(); - }); - }); - - it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', done => { - const newVm = createComponent(); - newVm.$mount(); - Vue.nextTick(() => { - expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search'); - newVm.$destroy(); - done(); - }); - }); - - it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', done => { - const newVm = createComponent(true); - newVm.$mount(); - Vue.nextTick(() => { - expect(newVm.searchEmptyMessage).toBe('No groups matched your search'); - newVm.$destroy(); - done(); - }); - }); - }); - - describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', done => { - spyOn(eventHub, '$off'); - - const newVm = createComponent(); - newVm.$mount(); - newVm.$destroy(); - - Vue.nextTick(() => { - expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('template', () => { - beforeEach(() => { - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('should render loading icon', done => { - vm.isLoading = true; - Vue.nextTick(() => { - expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); - expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups'); - done(); - }); - }); - - it('should render groups tree', done => { - vm.store.state.groups = [mockParentGroupItem]; - vm.isLoading = false; - Vue.nextTick(() => { - expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); - done(); - }); - }); - - it('renders modal confirmation dialog', done => { - vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?'; - vm.showModal = true; - Vue.nextTick(() => { - const modalDialogEl = vm.$el.querySelector('.modal'); - - expect(modalDialogEl).not.toBe(null); - expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?'); - expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave'); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/javascripts/groups/components/group_folder_spec.js deleted file mode 100644 index fdfd1b82bd8..00000000000 --- a/spec/javascripts/groups/components/group_folder_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import Vue from 'vue'; - -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import { mockGroups, mockParentGroupItem } from '../mock_data'; - -const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => { - const Component = Vue.extend(groupFolderComponent); - - return new Component({ - propsData: { - groups, - parentGroup, - }, - }); -}; - -describe('GroupFolderComponent', () => { - let vm; - - beforeEach(done => { - Vue.component('group-item', groupItemComponent); - - vm = createComponent(); - vm.$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('hasMoreChildren', () => { - it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => { - expect(vm.hasMoreChildren).toBeFalsy(); - }); - }); - - describe('moreChildrenStats', () => { - it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => { - expect(vm.moreChildrenStats).toBe('3 more items'); - }); - }); - }); - - describe('template', () => { - it('should render component template correctly', () => { - expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy(); - expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7); - }); - - it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => { - const parentGroup = Object.assign({}, mockParentGroupItem); - parentGroup.childrenCount = 21; - - const newVm = createComponent(mockGroups, parentGroup); - newVm.$mount(); - - expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined(); - newVm.$destroy(); - }); - }); -}); diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js deleted file mode 100644 index 2889d7ae4ff..00000000000 --- a/spec/javascripts/groups/components/group_item_spec.js +++ /dev/null @@ -1,218 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import eventHub from '~/groups/event_hub'; -import { mockParentGroupItem, mockChildren } from '../mock_data'; - -const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { - const Component = Vue.extend(groupItemComponent); - - return mountComponent(Component, { - group, - parentGroup, - }); -}; - -describe('GroupItemComponent', () => { - let vm; - - beforeEach(done => { - Vue.component('group-folder', groupFolderComponent); - - vm = createComponent(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('groupDomId', () => { - it('should return ID string suffixed with group ID', () => { - expect(vm.groupDomId).toBe('group-55'); - }); - }); - - describe('rowClass', () => { - it('should return map of classes based on group details', () => { - const classes = ['is-open', 'has-children', 'has-description', 'being-removed']; - const { rowClass } = vm; - - expect(Object.keys(rowClass).length).toBe(classes.length); - Object.keys(rowClass).forEach(className => { - expect(classes.indexOf(className)).toBeGreaterThan(-1); - }); - }); - }); - - describe('hasChildren', () => { - it('should return boolean value representing if group has any children present', () => { - let newVm; - const group = Object.assign({}, mockParentGroupItem); - - group.childrenCount = 5; - newVm = createComponent(group); - - expect(newVm.hasChildren).toBeTruthy(); - newVm.$destroy(); - - group.childrenCount = 0; - newVm = createComponent(group); - - expect(newVm.hasChildren).toBeFalsy(); - newVm.$destroy(); - }); - }); - - describe('hasAvatar', () => { - it('should return boolean value representing if group has any avatar present', () => { - let newVm; - const group = Object.assign({}, mockParentGroupItem); - - group.avatarUrl = null; - newVm = createComponent(group); - - expect(newVm.hasAvatar).toBeFalsy(); - newVm.$destroy(); - - group.avatarUrl = '/uploads/group_avatar.png'; - newVm = createComponent(group); - - expect(newVm.hasAvatar).toBeTruthy(); - newVm.$destroy(); - }); - }); - - describe('isGroup', () => { - it('should return boolean value representing if group item is of type `group` or not', () => { - let newVm; - const group = Object.assign({}, mockParentGroupItem); - - group.type = 'group'; - newVm = createComponent(group); - - expect(newVm.isGroup).toBeTruthy(); - newVm.$destroy(); - - group.type = 'project'; - newVm = createComponent(group); - - expect(newVm.isGroup).toBeFalsy(); - newVm.$destroy(); - }); - }); - }); - - describe('methods', () => { - describe('onClickRowGroup', () => { - let event; - - beforeEach(() => { - const classList = { - contains() { - return false; - }, - }; - - event = { - target: { - classList, - parentElement: { - classList, - }, - }, - }; - }); - - it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => { - spyOn(eventHub, '$emit'); - - vm.onClickRowGroup(event); - - expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group); - }); - - it('should navigate page to group homepage if group does not have any children present', done => { - const group = Object.assign({}, mockParentGroupItem); - group.childrenCount = 0; - const newVm = createComponent(group); - const visitUrl = spyOnDependency(groupItemComponent, 'visitUrl').and.stub(); - spyOn(eventHub, '$emit'); - - newVm.onClickRowGroup(event); - setTimeout(() => { - expect(eventHub.$emit).not.toHaveBeenCalled(); - expect(visitUrl).toHaveBeenCalledWith(newVm.group.relativePath); - done(); - }, 0); - }); - }); - }); - - describe('template', () => { - let group = null; - - describe('for a group pending deletion', () => { - beforeEach(() => { - group = { ...mockParentGroupItem, pendingRemoval: true }; - vm = createComponent(group); - }); - - it('renders the group pending removal badge', () => { - const badgeEl = vm.$el.querySelector('.badge-warning'); - - expect(badgeEl).toBeDefined(); - expect(badgeEl).toContainText('pending removal'); - }); - }); - - describe('for a group not scheduled for deletion', () => { - beforeEach(() => { - group = { ...mockParentGroupItem, pendingRemoval: false }; - vm = createComponent(group); - }); - - it('does not render the group pending removal badge', () => { - const groupTextContainer = vm.$el.querySelector('.group-text-container'); - - expect(groupTextContainer).not.toContainText('pending removal'); - }); - }); - - it('should render component template correctly', () => { - const visibilityIconEl = vm.$el.querySelector('.item-visibility'); - - expect(vm.$el.getAttribute('id')).toBe('group-55'); - expect(vm.$el.classList.contains('group-row')).toBeTruthy(); - - expect(vm.$el.querySelector('.group-row-contents')).toBeDefined(); - expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined(); - expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined(); - - expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined(); - expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined(); - expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined(); - - expect(vm.$el.querySelector('.avatar-container')).toBeDefined(); - expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined(); - expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined(); - - expect(vm.$el.querySelector('.title')).toBeDefined(); - expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined(); - - expect(visibilityIconEl).not.toBe(null); - expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip); - expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); - - expect(vm.$el.querySelector('.access-type')).toBeDefined(); - expect(vm.$el.querySelector('.description')).toBeDefined(); - - expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); - }); - }); -}); diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js deleted file mode 100644 index 8423467742e..00000000000 --- a/spec/javascripts/groups/components/groups_spec.js +++ /dev/null @@ -1,76 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import groupsComponent from '~/groups/components/groups.vue'; -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import eventHub from '~/groups/event_hub'; -import { mockGroups, mockPageInfo } from '../mock_data'; - -const createComponent = (searchEmpty = false) => { - const Component = Vue.extend(groupsComponent); - - return mountComponent(Component, { - groups: mockGroups, - pageInfo: mockPageInfo, - searchEmptyMessage: 'No matching results', - searchEmpty, - }); -}; - -describe('GroupsComponent', () => { - let vm; - - beforeEach(done => { - Vue.component('group-folder', groupFolderComponent); - Vue.component('group-item', groupItemComponent); - - vm = createComponent(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('methods', () => { - describe('change', () => { - it('should emit `fetchPage` event when page is changed via pagination', () => { - spyOn(eventHub, '$emit').and.stub(); - - vm.change(2); - - expect(eventHub.$emit).toHaveBeenCalledWith( - 'fetchPage', - 2, - jasmine.any(Object), - jasmine.any(Object), - jasmine.any(Object), - ); - }); - }); - }); - - describe('template', () => { - it('should render component template correctly', done => { - Vue.nextTick(() => { - expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); - expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); - expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); - expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0); - done(); - }); - }); - - it('should render empty search message when `searchEmpty` is `true`', done => { - vm.searchEmpty = true; - Vue.nextTick(() => { - expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined(); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js deleted file mode 100644 index 9a9d6208eac..00000000000 --- a/spec/javascripts/groups/components/item_actions_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import itemActionsComponent from '~/groups/components/item_actions.vue'; -import eventHub from '~/groups/event_hub'; -import { mockParentGroupItem, mockChildren } from '../mock_data'; - -const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { - const Component = Vue.extend(itemActionsComponent); - - return mountComponent(Component, { - group, - parentGroup, - }); -}; - -describe('ItemActionsComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('methods', () => { - describe('onLeaveGroup', () => { - it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => { - spyOn(eventHub, '$emit'); - vm.onLeaveGroup(); - - expect(eventHub.$emit).toHaveBeenCalledWith( - 'showLeaveGroupModal', - vm.group, - vm.parentGroup, - ); - }); - }); - }); - - describe('template', () => { - it('should render component template correctly', () => { - expect(vm.$el.classList.contains('controls')).toBeTruthy(); - }); - - it('should render Edit Group button with correct attribute values', () => { - const group = Object.assign({}, mockParentGroupItem); - group.canEdit = true; - const newVm = createComponent(group); - - const editBtn = newVm.$el.querySelector('a.edit-group'); - - expect(editBtn).toBeDefined(); - expect(editBtn.classList.contains('no-expand')).toBeTruthy(); - expect(editBtn.getAttribute('href')).toBe(group.editPath); - expect(editBtn.getAttribute('aria-label')).toBe('Edit group'); - expect(editBtn.dataset.originalTitle).toBe('Edit group'); - expect(editBtn.querySelectorAll('svg use').length).not.toBe(0); - expect(editBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#settings'); - - newVm.$destroy(); - }); - - it('should render Leave Group button with correct attribute values', () => { - const group = Object.assign({}, mockParentGroupItem); - group.canLeave = true; - const newVm = createComponent(group); - - const leaveBtn = newVm.$el.querySelector('a.leave-group'); - - expect(leaveBtn).toBeDefined(); - expect(leaveBtn.classList.contains('no-expand')).toBeTruthy(); - expect(leaveBtn.getAttribute('href')).toBe(group.leavePath); - expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group'); - expect(leaveBtn.dataset.originalTitle).toBe('Leave this group'); - expect(leaveBtn.querySelectorAll('svg use').length).not.toBe(0); - expect(leaveBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#leave'); - - newVm.$destroy(); - }); - }); -}); diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js deleted file mode 100644 index 0eb56abbd61..00000000000 --- a/spec/javascripts/groups/components/item_caret_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import itemCaretComponent from '~/groups/components/item_caret.vue'; - -const createComponent = (isGroupOpen = false) => { - const Component = Vue.extend(itemCaretComponent); - - return mountComponent(Component, { - isGroupOpen, - }); -}; - -describe('ItemCaretComponent', () => { - describe('template', () => { - it('should render component template correctly', () => { - const vm = createComponent(); - - expect(vm.$el.classList.contains('folder-caret')).toBeTruthy(); - expect(vm.$el.querySelectorAll('svg').length).toBe(1); - vm.$destroy(); - }); - - it('should render caret down icon if `isGroupOpen` prop is `true`', () => { - const vm = createComponent(true); - - expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down'); - vm.$destroy(); - }); - - it('should render caret right icon if `isGroupOpen` prop is `false`', () => { - const vm = createComponent(); - - expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right'); - vm.$destroy(); - }); - }); -}); diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js deleted file mode 100644 index 13d17b87d76..00000000000 --- a/spec/javascripts/groups/components/item_stats_spec.js +++ /dev/null @@ -1,128 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import itemStatsComponent from '~/groups/components/item_stats.vue'; -import { - mockParentGroupItem, - ITEM_TYPE, - VISIBILITY_TYPE_ICON, - GROUP_VISIBILITY_TYPE, - PROJECT_VISIBILITY_TYPE, -} from '../mock_data'; - -const createComponent = (item = mockParentGroupItem) => { - const Component = Vue.extend(itemStatsComponent); - - return mountComponent(Component, { - item, - }); -}; - -describe('ItemStatsComponent', () => { - describe('computed', () => { - describe('visibilityIcon', () => { - it('should return icon class based on `item.visibility` value', () => { - Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => { - const item = Object.assign({}, mockParentGroupItem, { visibility }); - const vm = createComponent(item); - - expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]); - vm.$destroy(); - }); - }); - }); - - describe('visibilityTooltip', () => { - it('should return tooltip string for Group based on `item.visibility` value', () => { - Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => { - const item = Object.assign({}, mockParentGroupItem, { - visibility, - type: ITEM_TYPE.GROUP, - }); - const vm = createComponent(item); - - expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]); - vm.$destroy(); - }); - }); - - it('should return tooltip string for Project based on `item.visibility` value', () => { - Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => { - const item = Object.assign({}, mockParentGroupItem, { - visibility, - type: ITEM_TYPE.PROJECT, - }); - const vm = createComponent(item); - - expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]); - vm.$destroy(); - }); - }); - }); - - describe('isProject', () => { - it('should return boolean value representing whether `item.type` is Project or not', () => { - let item; - let vm; - - item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT }); - vm = createComponent(item); - - expect(vm.isProject).toBeTruthy(); - vm.$destroy(); - - item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); - vm = createComponent(item); - - expect(vm.isProject).toBeFalsy(); - vm.$destroy(); - }); - }); - - describe('isGroup', () => { - it('should return boolean value representing whether `item.type` is Group or not', () => { - let item; - let vm; - - item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); - vm = createComponent(item); - - expect(vm.isGroup).toBeTruthy(); - vm.$destroy(); - - item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT }); - vm = createComponent(item); - - expect(vm.isGroup).toBeFalsy(); - vm.$destroy(); - }); - }); - }); - - describe('template', () => { - it('renders component container element correctly', () => { - const vm = createComponent(); - - expect(vm.$el.classList.contains('stats')).toBeTruthy(); - - vm.$destroy(); - }); - - it('renders start count and last updated information for project item correctly', () => { - const item = Object.assign({}, mockParentGroupItem, { - type: ITEM_TYPE.PROJECT, - starCount: 4, - }); - const vm = createComponent(item); - - const projectStarIconEl = vm.$el.querySelector('.project-stars'); - - expect(projectStarIconEl).not.toBeNull(); - expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); - expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0); - expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0); - - vm.$destroy(); - }); - }); -}); diff --git a/spec/javascripts/groups/components/item_stats_value_spec.js b/spec/javascripts/groups/components/item_stats_value_spec.js deleted file mode 100644 index ff4e781ce1a..00000000000 --- a/spec/javascripts/groups/components/item_stats_value_spec.js +++ /dev/null @@ -1,82 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import itemStatsValueComponent from '~/groups/components/item_stats_value.vue'; - -const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => { - const Component = Vue.extend(itemStatsValueComponent); - - return mountComponent(Component, { - title, - cssClass, - iconName, - tooltipPlacement, - value, - }); -}; - -describe('ItemStatsValueComponent', () => { - describe('computed', () => { - let vm; - const itemConfig = { - title: 'Subgroups', - cssClass: 'number-subgroups', - iconName: 'folder', - tooltipPlacement: 'left', - }; - - describe('isValuePresent', () => { - it('returns true if non-empty `value` is present', () => { - vm = createComponent(Object.assign({}, itemConfig, { value: 10 })); - - expect(vm.isValuePresent).toBeTruthy(); - }); - - it('returns false if empty `value` is present', () => { - vm = createComponent(itemConfig); - - expect(vm.isValuePresent).toBeFalsy(); - }); - - afterEach(() => { - vm.$destroy(); - }); - }); - }); - - describe('template', () => { - let vm; - beforeEach(() => { - vm = createComponent({ - title: 'Subgroups', - cssClass: 'number-subgroups', - iconName: 'folder', - tooltipPlacement: 'left', - value: 10, - }); - }); - - it('renders component element correctly', () => { - expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy(); - expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0); - expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0); - }); - - it('renders element tooltip correctly', () => { - expect(vm.$el.dataset.originalTitle).toBe('Subgroups'); - expect(vm.$el.dataset.placement).toBe('left'); - }); - - it('renders element icon correctly', () => { - expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder'); - }); - - it('renders value count correctly', () => { - expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10'); - }); - - afterEach(() => { - vm.$destroy(); - }); - }); -}); diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js deleted file mode 100644 index 321712e54a6..00000000000 --- a/spec/javascripts/groups/components/item_type_icon_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import itemTypeIconComponent from '~/groups/components/item_type_icon.vue'; -import { ITEM_TYPE } from '../mock_data'; - -const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => { - const Component = Vue.extend(itemTypeIconComponent); - - return mountComponent(Component, { - itemType, - isGroupOpen, - }); -}; - -describe('ItemTypeIconComponent', () => { - describe('template', () => { - it('should render component template correctly', () => { - const vm = createComponent(); - vm.$mount(); - - expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy(); - vm.$destroy(); - }); - - it('should render folder open or close icon based `isGroupOpen` prop value', () => { - let vm; - - vm = createComponent(ITEM_TYPE.GROUP, true); - vm.$mount(); - - expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open'); - vm.$destroy(); - - vm = createComponent(ITEM_TYPE.GROUP); - vm.$mount(); - - expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder'); - vm.$destroy(); - }); - - it('should render bookmark icon based on `isProject` prop value', () => { - let vm; - - vm = createComponent(ITEM_TYPE.PROJECT); - vm.$mount(); - - expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark'); - vm.$destroy(); - - vm = createComponent(ITEM_TYPE.GROUP); - vm.$mount(); - - expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark'); - vm.$destroy(); - }); - }); -}); diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js deleted file mode 100644 index 380dda9f7b1..00000000000 --- a/spec/javascripts/groups/mock_data.js +++ /dev/null @@ -1,398 +0,0 @@ -export const mockEndpoint = '/dashboard/groups.json'; - -export const ITEM_TYPE = { - PROJECT: 'project', - GROUP: 'group', -}; - -export const GROUP_VISIBILITY_TYPE = { - public: 'Public - The group and any public projects can be viewed without any authentication.', - internal: 'Internal - The group and any internal projects can be viewed by any logged in user.', - private: 'Private - The group and its projects can only be viewed by members.', -}; - -export const PROJECT_VISIBILITY_TYPE = { - public: 'Public - The project can be accessed without any authentication.', - internal: 'Internal - The project can be accessed by any logged in user.', - private: - 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', -}; - -export const VISIBILITY_TYPE_ICON = { - public: 'earth', - internal: 'shield', - private: 'lock', -}; - -export const mockParentGroupItem = { - id: 55, - name: 'hardware', - description: '', - visibility: 'public', - fullName: 'platform / hardware', - relativePath: '/platform/hardware', - canEdit: true, - type: 'group', - avatarUrl: null, - permission: 'Owner', - editPath: '/groups/platform/hardware/edit', - childrenCount: 3, - leavePath: '/groups/platform/hardware/group_members/leave', - parentId: 54, - memberCount: '1', - projectCount: 1, - subgroupCount: 2, - canLeave: false, - children: [], - isOpen: true, - isChildrenLoading: false, - isBeingRemoved: false, - updatedAt: '2017-04-09T18:40:39.101Z', -}; - -export const mockRawChildren = [ - { - id: 57, - name: 'bsp', - description: '', - visibility: 'public', - full_name: 'platform / hardware / bsp', - relative_path: '/platform/hardware/bsp', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/platform/hardware/bsp/edit', - children_count: 6, - leave_path: '/groups/platform/hardware/bsp/group_members/leave', - parent_id: 55, - number_users_with_delimiter: '1', - project_count: 4, - subgroup_count: 2, - can_leave: false, - children: [], - updated_at: '2017-04-09T18:40:39.101Z', - }, -]; - -export const mockChildren = [ - { - id: 57, - name: 'bsp', - description: '', - visibility: 'public', - fullName: 'platform / hardware / bsp', - relativePath: '/platform/hardware/bsp', - canEdit: true, - type: 'group', - avatarUrl: null, - permission: 'Owner', - editPath: '/groups/platform/hardware/bsp/edit', - childrenCount: 6, - leavePath: '/groups/platform/hardware/bsp/group_members/leave', - parentId: 55, - memberCount: '1', - projectCount: 4, - subgroupCount: 2, - canLeave: false, - children: [], - isOpen: true, - isChildrenLoading: false, - isBeingRemoved: false, - updatedAt: '2017-04-09T18:40:39.101Z', - }, -]; - -export const mockGroups = [ - { - id: 75, - name: 'test-group', - description: '', - visibility: 'public', - full_name: 'test-group', - relative_path: '/test-group', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/test-group/edit', - children_count: 2, - leave_path: '/groups/test-group/group_members/leave', - parent_id: null, - number_users_with_delimiter: '1', - project_count: 2, - subgroup_count: 0, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - }, - { - id: 67, - name: 'open-source', - description: '', - visibility: 'private', - full_name: 'open-source', - relative_path: '/open-source', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/open-source/edit', - children_count: 0, - leave_path: '/groups/open-source/group_members/leave', - parent_id: null, - number_users_with_delimiter: '1', - project_count: 0, - subgroup_count: 0, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - }, - { - id: 54, - name: 'platform', - description: '', - visibility: 'public', - full_name: 'platform', - relative_path: '/platform', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/platform/edit', - children_count: 1, - leave_path: '/groups/platform/group_members/leave', - parent_id: null, - number_users_with_delimiter: '1', - project_count: 0, - subgroup_count: 1, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - }, - { - id: 5, - name: 'H5bp', - description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.', - visibility: 'public', - full_name: 'H5bp', - relative_path: '/h5bp', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/h5bp/edit', - children_count: 1, - leave_path: '/groups/h5bp/group_members/leave', - parent_id: null, - number_users_with_delimiter: '5', - project_count: 1, - subgroup_count: 0, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - }, - { - id: 4, - name: 'Twitter', - description: 'Deserunt hic nostrum placeat veniam.', - visibility: 'public', - full_name: 'Twitter', - relative_path: '/twitter', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/twitter/edit', - children_count: 2, - leave_path: '/groups/twitter/group_members/leave', - parent_id: null, - number_users_with_delimiter: '5', - project_count: 2, - subgroup_count: 0, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - }, - { - id: 3, - name: 'Documentcloud', - description: 'Consequatur saepe totam ea pariatur maxime.', - visibility: 'public', - full_name: 'Documentcloud', - relative_path: '/documentcloud', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/documentcloud/edit', - children_count: 1, - leave_path: '/groups/documentcloud/group_members/leave', - parent_id: null, - number_users_with_delimiter: '5', - project_count: 1, - subgroup_count: 0, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - }, - { - id: 2, - name: 'Gitlab Org', - description: 'Debitis ea quas aperiam velit doloremque ab.', - visibility: 'public', - full_name: 'Gitlab Org', - relative_path: '/gitlab-org', - can_edit: true, - type: 'group', - avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', - permission: 'Owner', - edit_path: '/groups/gitlab-org/edit', - children_count: 4, - leave_path: '/groups/gitlab-org/group_members/leave', - parent_id: null, - number_users_with_delimiter: '5', - project_count: 4, - subgroup_count: 0, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - }, -]; - -export const mockSearchedGroups = [ - { - id: 55, - name: 'hardware', - description: '', - visibility: 'public', - full_name: 'platform / hardware', - relative_path: '/platform/hardware', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/platform/hardware/edit', - children_count: 3, - leave_path: '/groups/platform/hardware/group_members/leave', - parent_id: 54, - number_users_with_delimiter: '1', - project_count: 1, - subgroup_count: 2, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - children: [ - { - id: 57, - name: 'bsp', - description: '', - visibility: 'public', - full_name: 'platform / hardware / bsp', - relative_path: '/platform/hardware/bsp', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/platform/hardware/bsp/edit', - children_count: 6, - leave_path: '/groups/platform/hardware/bsp/group_members/leave', - parent_id: 55, - number_users_with_delimiter: '1', - project_count: 4, - subgroup_count: 2, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - children: [ - { - id: 60, - name: 'kernel', - description: '', - visibility: 'public', - full_name: 'platform / hardware / bsp / kernel', - relative_path: '/platform/hardware/bsp/kernel', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/platform/hardware/bsp/kernel/edit', - children_count: 1, - leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave', - parent_id: 57, - number_users_with_delimiter: '1', - project_count: 0, - subgroup_count: 1, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - children: [ - { - id: 61, - name: 'common', - description: '', - visibility: 'public', - full_name: 'platform / hardware / bsp / kernel / common', - relative_path: '/platform/hardware/bsp/kernel/common', - can_edit: true, - type: 'group', - avatar_url: null, - permission: 'Owner', - edit_path: '/groups/platform/hardware/bsp/kernel/common/edit', - children_count: 2, - leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave', - parent_id: 60, - number_users_with_delimiter: '1', - project_count: 2, - subgroup_count: 0, - can_leave: false, - updated_at: '2017-04-09T18:40:39.101Z', - children: [ - { - id: 17, - name: 'v4.4', - description: - 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.', - visibility: 'public', - full_name: 'platform / hardware / bsp / kernel / common / v4.4', - relative_path: '/platform/hardware/bsp/kernel/common/v4.4', - can_edit: true, - type: 'project', - avatar_url: null, - permission: null, - edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit', - star_count: 0, - updated_at: '2017-09-12T06:37:04.925Z', - }, - { - id: 16, - name: 'v4.1', - description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.', - visibility: 'public', - full_name: 'platform / hardware / bsp / kernel / common / v4.1', - relative_path: '/platform/hardware/bsp/kernel/common/v4.1', - can_edit: true, - type: 'project', - avatar_url: null, - permission: null, - edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit', - star_count: 0, - updated_at: '2017-04-09T18:41:03.112Z', - }, - ], - }, - ], - }, - ], - }, - ], - }, -]; - -export const mockRawPageInfo = { - 'x-per-page': 10, - 'x-page': 10, - 'x-total': 10, - 'x-total-pages': 10, - 'x-next-page': 10, - 'x-prev-page': 10, -}; - -export const mockPageInfo = { - perPage: 10, - page: 10, - total: 10, - totalPages: 10, - nextPage: 10, - prevPage: 10, -}; diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/javascripts/groups/service/groups_service_spec.js deleted file mode 100644 index 45db962a1ef..00000000000 --- a/spec/javascripts/groups/service/groups_service_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -import GroupsService from '~/groups/service/groups_service'; -import { mockEndpoint, mockParentGroupItem } from '../mock_data'; - -describe('GroupsService', () => { - let service; - - beforeEach(() => { - service = new GroupsService(mockEndpoint); - }); - - describe('getGroups', () => { - it('should return promise for `GET` request on provided endpoint', () => { - spyOn(axios, 'get').and.stub(); - const params = { - page: 2, - filter: 'git', - sort: 'created_asc', - archived: true, - }; - - service.getGroups(55, 2, 'git', 'created_asc', true); - - expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params: { parent_id: 55 } }); - - service.getGroups(null, 2, 'git', 'created_asc', true); - - expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params }); - }); - }); - - describe('leaveGroup', () => { - it('should return promise for `DELETE` request on provided endpoint', () => { - spyOn(axios, 'delete').and.stub(); - - service.leaveGroup(mockParentGroupItem.leavePath); - - expect(axios.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath); - }); - }); -}); diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/javascripts/groups/store/groups_store_spec.js deleted file mode 100644 index 38de4b89f31..00000000000 --- a/spec/javascripts/groups/store/groups_store_spec.js +++ /dev/null @@ -1,123 +0,0 @@ -import GroupsStore from '~/groups/store/groups_store'; -import { - mockGroups, - mockSearchedGroups, - mockParentGroupItem, - mockRawChildren, - mockRawPageInfo, -} from '../mock_data'; - -describe('ProjectsStore', () => { - describe('constructor', () => { - it('should initialize default state', () => { - let store; - - store = new GroupsStore(); - - expect(Object.keys(store.state).length).toBe(2); - expect(Array.isArray(store.state.groups)).toBeTruthy(); - expect(Object.keys(store.state.pageInfo).length).toBe(0); - expect(store.hideProjects).not.toBeDefined(); - - store = new GroupsStore(true); - - expect(store.hideProjects).toBeTruthy(); - }); - }); - - describe('setGroups', () => { - it('should set groups to state', () => { - const store = new GroupsStore(); - spyOn(store, 'formatGroupItem').and.callThrough(); - - store.setGroups(mockGroups); - - expect(store.state.groups.length).toBe(mockGroups.length); - expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); - expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1); - }); - }); - - describe('setSearchedGroups', () => { - it('should set searched groups to state', () => { - const store = new GroupsStore(); - spyOn(store, 'formatGroupItem').and.callThrough(); - - store.setSearchedGroups(mockSearchedGroups); - - expect(store.state.groups.length).toBe(mockSearchedGroups.length); - expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); - expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1); - expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName')).toBeGreaterThan( - -1, - ); - }); - }); - - describe('setGroupChildren', () => { - it('should set children to group item in state', () => { - const store = new GroupsStore(); - spyOn(store, 'formatGroupItem').and.callThrough(); - - store.setGroupChildren(mockParentGroupItem, mockRawChildren); - - expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); - expect(mockParentGroupItem.children.length).toBe(1); - expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1); - expect(mockParentGroupItem.isOpen).toBeTruthy(); - expect(mockParentGroupItem.isChildrenLoading).toBeFalsy(); - }); - }); - - describe('setPaginationInfo', () => { - it('should parse and set pagination info in state', () => { - const store = new GroupsStore(); - - store.setPaginationInfo(mockRawPageInfo); - - expect(store.state.pageInfo.perPage).toBe(10); - expect(store.state.pageInfo.page).toBe(10); - expect(store.state.pageInfo.total).toBe(10); - expect(store.state.pageInfo.totalPages).toBe(10); - expect(store.state.pageInfo.nextPage).toBe(10); - expect(store.state.pageInfo.previousPage).toBe(10); - }); - }); - - describe('formatGroupItem', () => { - it('should parse group item object and return updated object', () => { - let store; - let updatedGroupItem; - - store = new GroupsStore(); - updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); - - expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); - expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count); - expect(updatedGroupItem.isChildrenLoading).toBe(false); - expect(updatedGroupItem.isBeingRemoved).toBe(false); - - store = new GroupsStore(true); - updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); - - expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); - expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count); - }); - }); - - describe('removeGroup', () => { - it('should remove children from group item in state', () => { - const store = new GroupsStore(); - const rawParentGroup = Object.assign({}, mockGroups[0]); - const rawChildGroup = Object.assign({}, mockGroups[1]); - - store.setGroups([rawParentGroup]); - store.setGroupChildren(store.state.groups[0], [rawChildGroup]); - const childItem = store.state.groups[0].children[0]; - - store.removeGroup(childItem, store.state.groups[0]); - - expect(store.state.groups[0].children.length).toBe(0); - }); - }); -}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js deleted file mode 100644 index 76827cde093..00000000000 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js +++ /dev/null @@ -1,99 +0,0 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; -import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; -import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; -import { mockStore } from '../mock_data'; - -const localVue = createLocalVue(); - -describe('MrWidgetPipelineContainer', () => { - let wrapper; - - const factory = (props = {}) => { - wrapper = mount(localVue.extend(MrWidgetPipelineContainer), { - propsData: { - mr: Object.assign({}, mockStore), - ...props, - }, - localVue, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when pre merge', () => { - beforeEach(() => { - factory(); - }); - - it('renders pipeline', () => { - expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); - expect(wrapper.find(MrWidgetPipeline).props()).toEqual( - jasmine.objectContaining({ - pipeline: mockStore.pipeline, - pipelineCoverageDelta: mockStore.pipelineCoverageDelta, - ciStatus: mockStore.ciStatus, - hasCi: mockStore.hasCI, - sourceBranch: mockStore.sourceBranch, - sourceBranchLink: mockStore.sourceBranchLink, - }), - ); - }); - - it('renders deployments', () => { - const expectedProps = mockStore.deployments.map(dep => - jasmine.objectContaining({ - deployment: dep, - showMetrics: false, - }), - ); - - const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment'); - - expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps); - }); - }); - - describe('when post merge', () => { - beforeEach(() => { - factory({ - isPostMerge: true, - }); - }); - - it('renders pipeline', () => { - expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); - expect(wrapper.find(MrWidgetPipeline).props()).toEqual( - jasmine.objectContaining({ - pipeline: mockStore.mergePipeline, - pipelineCoverageDelta: mockStore.pipelineCoverageDelta, - ciStatus: mockStore.ciStatus, - hasCi: mockStore.hasCI, - sourceBranch: mockStore.targetBranch, - sourceBranchLink: mockStore.targetBranch, - }), - ); - }); - - it('renders deployments', () => { - const expectedProps = mockStore.postMergeDeployments.map(dep => - jasmine.objectContaining({ - deployment: dep, - showMetrics: true, - }), - ); - - const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment'); - - expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps); - }); - }); - - describe('with artifacts path', () => { - it('renders the artifacts app', () => { - expect(wrapper.find(ArtifactsApp).isVisible()).toBe(true); - }); - }); -}); diff --git a/spec/lib/api/entities/branch_spec.rb b/spec/lib/api/entities/branch_spec.rb new file mode 100644 index 00000000000..604f56c0cb2 --- /dev/null +++ b/spec/lib/api/entities/branch_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Entities::Branch do + describe '#as_json' do + subject { entity.as_json } + + let(:project) { create(:project, :public, :repository) } + let(:repository) { project.repository } + let(:branch) { repository.find_branch('master') } + let(:entity) { described_class.new(branch, project: project) } + + it 'includes basic fields', :aggregate_failures do + is_expected.to include( + name: 'master', + commit: a_kind_of(Hash), + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: false, + default: true, + web_url: Gitlab::Routing.url_helpers.project_tree_url(project, 'master') + ) + end + end +end diff --git a/spec/lib/gitlab/alert_management/alert_params_spec.rb b/spec/lib/gitlab/alert_management/alert_params_spec.rb new file mode 100644 index 00000000000..8c60b502417 --- /dev/null +++ b/spec/lib/gitlab/alert_management/alert_params_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::AlertManagement::AlertParams do + let_it_be(:project) { create(:project, :repository, :private) } + + describe '.from_generic_alert' do + let(:started_at) { Time.current.change(usec: 0).rfc3339 } + let(:payload) do + { + 'title' => 'Alert title', + 'description' => 'Description', + 'monitoring_tool' => 'Monitoring tool name', + 'service' => 'Service', + 'hosts' => ['gitlab.com'], + 'start_time' => started_at, + 'some' => { 'extra' => { 'payload' => 'here' } } + } + end + + subject { described_class.from_generic_alert(project: project, payload: payload) } + + it 'returns Alert compatible parameters' do + is_expected.to eq( + project_id: project.id, + title: 'Alert title', + description: 'Description', + monitoring_tool: 'Monitoring tool name', + service: 'Service', + hosts: ['gitlab.com'], + payload: payload, + started_at: started_at + ) + end + + context 'when there are no hosts in the payload' do + let(:payload) { {} } + + it 'hosts param is an empty array' do + expect(subject[:hosts]).to be_empty + end + end + end +end diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb index f08ecd397ec..8315d2292a0 100644 --- a/spec/services/projects/alerting/notify_service_spec.rb +++ b/spec/services/projects/alerting/notify_service_spec.rb @@ -12,11 +12,16 @@ describe Projects::Alerting::NotifyService do shared_examples 'processes incident issues' do |amount| let(:create_incident_service) { spy } + let(:new_alert) { instance_double(AlertManagement::Alert, id: 503, persisted?: true) } it 'processes issues' do + expect(AlertManagement::Alert) + .to receive(:create) + .and_return(new_alert) + expect(IncidentManagement::ProcessAlertWorker) .to receive(:perform_async) - .with(project.id, kind_of(Hash)) + .with(project.id, kind_of(Hash), new_alert.id) .exactly(amount).times Sidekiq::Testing.inline! do @@ -59,6 +64,12 @@ describe Projects::Alerting::NotifyService do end end + shared_examples 'NotifyService does not create alert' do + it 'does not create alert' do + expect { subject }.not_to change(AlertManagement::Alert, :count) + end + end + describe '#execute' do let(:token) { 'invalid-token' } let(:starts_at) { Time.now.change(usec: 0) } @@ -88,6 +99,36 @@ describe Projects::Alerting::NotifyService do .and_return(incident_management_setting) end + context 'with valid payload' do + it 'creates AlertManagement::Alert' do + expect { subject }.to change(AlertManagement::Alert, :count).by(1) + end + + it 'created alert has all data properly assigned' do + subject + + alert = AlertManagement::Alert.last + alert_attributes = alert.attributes.except('id', 'iid', 'created_at', 'updated_at') + + expect(alert_attributes).to eq( + 'project_id' => project.id, + 'issue_id' => nil, + 'fingerprint' => nil, + 'title' => 'alert title', + 'description' => nil, + 'monitoring_tool' => nil, + 'service' => nil, + 'hosts' => [], + 'payload' => payload_raw, + 'severity' => 'critical', + 'status' => 'triggered', + 'events' => 1, + 'started_at' => alert.started_at, + 'ended_at' => nil + ) + end + end + it_behaves_like 'does not process incident issues' context 'issue enabled' do @@ -103,6 +144,7 @@ describe Projects::Alerting::NotifyService do end it_behaves_like 'does not process incident issues due to error', http_status: :bad_request + it_behaves_like 'NotifyService does not create alert' end end @@ -115,12 +157,14 @@ describe Projects::Alerting::NotifyService do context 'with invalid token' do it_behaves_like 'does not process incident issues due to error', http_status: :unauthorized + it_behaves_like 'NotifyService does not create alert' end context 'with deactivated Alerts Service' do let!(:alerts_service) { create(:alerts_service, :inactive, project: project) } it_behaves_like 'does not process incident issues due to error', http_status: :forbidden + it_behaves_like 'NotifyService does not create alert' end end end diff --git a/spec/workers/incident_management/process_alert_worker_spec.rb b/spec/workers/incident_management/process_alert_worker_spec.rb index 9f40833dfd7..2a0c12b010d 100644 --- a/spec/workers/incident_management/process_alert_worker_spec.rb +++ b/spec/workers/incident_management/process_alert_worker_spec.rb @@ -6,16 +6,24 @@ describe IncidentManagement::ProcessAlertWorker do let_it_be(:project) { create(:project) } describe '#perform' do - let(:alert) { :alert } - let(:create_issue_service) { spy(:create_issue_service) } + let(:alert_management_alert_id) { nil } + let(:alert_payload) { { alert: 'payload' } } + let(:new_issue) { create(:issue, project: project) } + let(:create_issue_service) { instance_double(IncidentManagement::CreateIssueService, execute: new_issue) } - subject { described_class.new.perform(project.id, alert) } + subject { described_class.new.perform(project.id, alert_payload, alert_management_alert_id) } + + before do + allow(IncidentManagement::CreateIssueService) + .to receive(:new).with(project, alert_payload) + .and_return(create_issue_service) + end it 'calls create issue service' do expect(Project).to receive(:find_by_id).and_call_original expect(IncidentManagement::CreateIssueService) - .to receive(:new).with(project, :alert) + .to receive(:new).with(project, alert_payload) .and_return(create_issue_service) expect(create_issue_service).to receive(:execute) @@ -26,7 +34,7 @@ describe IncidentManagement::ProcessAlertWorker do context 'with invalid project' do let(:invalid_project_id) { 0 } - subject { described_class.new.perform(invalid_project_id, alert) } + subject { described_class.new.perform(invalid_project_id, alert_payload) } it 'does not create issues' do expect(Project).to receive(:find_by_id).and_call_original @@ -35,5 +43,54 @@ describe IncidentManagement::ProcessAlertWorker do subject end end + + context 'when alert_management_alert_id is present' do + let!(:alert) { create(:alert_management_alert, project: project) } + let(:alert_management_alert_id) { alert.id } + + before do + allow(AlertManagement::Alert) + .to receive(:find_by_id) + .with(alert_management_alert_id) + .and_return(alert) + + allow(Gitlab::GitLogger).to receive(:warn).and_call_original + end + + context 'when alert can be updated' do + it 'updates AlertManagement::Alert#issue_id' do + expect { subject }.to change { alert.reload.issue_id }.to(new_issue.id) + end + + it 'does not write a warning to log' do + subject + + expect(Gitlab::GitLogger).not_to have_received(:warn) + end + end + + context 'when alert cannot be updated' do + before do + # invalidate alert + too_many_hosts = Array.new(AlertManagement::Alert::HOSTS_MAX_LENGTH + 1) { |_| 'host' } + alert.update_columns(hosts: too_many_hosts) + end + + it 'updates AlertManagement::Alert#issue_id' do + expect { subject }.not_to change { alert.reload.issue_id } + end + + it 'writes a worning to log' do + subject + + expect(Gitlab::GitLogger).to have_received(:warn).with( + message: 'Cannot link an Issue with Alert', + issue_id: new_issue.id, + alert_id: alert_management_alert_id, + alert_errors: { hosts: ['hosts array is over 255 chars'] } + ) + end + end + end end end -- cgit v1.2.1