From df8cd83eab416ae3ba34bb5cfdf423eb41de4c6e Mon Sep 17 00:00:00 2001 From: Lee Tickett <ltickett@gitlab.com> Date: Fri, 13 Jan 2023 07:25:16 +0000 Subject: [PATCH] Add create achievement GraphQL mutation Changelog: added --- app/controllers/uploads_controller.rb | 5 +- app/graphql/mutations/achievements/create.rb | 54 +++++++++++++ .../types/achievements/achievement_type.rb | 55 +++++++++++++ app/graphql/types/mutation_type.rb | 1 + app/graphql/types/namespace_type.rb | 11 +++ .../achievements/achievement_policy.rb | 7 ++ app/policies/group_policy.rb | 2 + app/services/achievements/base_service.rb | 20 +++++ app/services/achievements/create_service.rb | 25 ++++++ .../development/achievements.yml | 8 ++ config/routes/uploads.rb | 2 +- doc/api/graphql/reference/index.md | 69 ++++++++++++++++ spec/controllers/uploads_controller_spec.rb | 40 ++++++++++ .../mutations/achievements/create_spec.rb | 54 +++++++++++++ .../achievements/achievement_type_spec.rb | 39 ++++++++++ spec/graphql/types/namespace_type_spec.rb | 2 +- .../mutations/achievements/create_spec.rb | 78 +++++++++++++++++++ .../achievements/create_service_spec.rb | 46 +++++++++++ .../policies/group_policy_shared_context.rb | 3 +- 19 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 app/graphql/mutations/achievements/create.rb create mode 100644 app/graphql/types/achievements/achievement_type.rb create mode 100644 app/policies/achievements/achievement_policy.rb create mode 100644 app/services/achievements/base_service.rb create mode 100644 app/services/achievements/create_service.rb create mode 100644 config/feature_flags/development/achievements.yml create mode 100644 spec/graphql/mutations/achievements/create_spec.rb create mode 100644 spec/graphql/types/achievements/achievement_type_spec.rb create mode 100644 spec/requests/api/graphql/mutations/achievements/create_spec.rb create mode 100644 spec/services/achievements/create_service_spec.rb diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 66f715f32af63..ea99aa1235024 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -15,6 +15,7 @@ class UploadsController < ApplicationController "personal_snippet" => PersonalSnippet, "projects/topic" => Projects::Topic, 'alert_management_metric_image' => ::AlertManagement::MetricImage, + "achievements/achievement" => Achievements::Achievement, nil => PersonalSnippet }.freeze @@ -61,6 +62,8 @@ def authorized? true when ::AlertManagement::MetricImage can?(current_user, :read_alert_management_metric_image, model.alert) + when ::Achievements::Achievement + true else can?(current_user, "read_#{model.class.underscore}".to_sym, model) end @@ -92,7 +95,7 @@ def render_unauthorized def cache_settings case model - when User, Appearance, Projects::Topic + when User, Appearance, Projects::Topic, Achievements::Achievement [5.minutes, { public: true, must_revalidate: false }] when Project, Group [5.minutes, { private: true, must_revalidate: true }] diff --git a/app/graphql/mutations/achievements/create.rb b/app/graphql/mutations/achievements/create.rb new file mode 100644 index 0000000000000..6cfe6c0e64311 --- /dev/null +++ b/app/graphql/mutations/achievements/create.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Mutations + module Achievements + class Create < BaseMutation + graphql_name 'AchievementsCreate' + + include Gitlab::Graphql::Authorize::AuthorizeResource + + field :achievement, + ::Types::Achievements::AchievementType, + null: true, + description: 'Achievement created.' + + argument :namespace_id, ::Types::GlobalIDType[::Namespace], + required: true, + description: 'Namespace for the achievement.' + + argument :name, GraphQL::Types::String, + required: true, + description: 'Name for the achievement.' + + argument :avatar, ApolloUploadServer::Upload, + required: false, + description: 'Avatar for the achievement.' + + argument :description, GraphQL::Types::String, + required: false, + description: 'Description of or notes for the achievement.' + + argument :revokeable, GraphQL::Types::Boolean, + required: true, + description: 'Revokeability for the achievement.' + + authorize :admin_achievement + + def resolve(args) + namespace = authorized_find!(id: args[:namespace_id]) + + raise Gitlab::Graphql::Errors::ResourceNotAvailable, '`achievements` feature flag is disabled.' \ + if Feature.disabled?(:achievements, namespace) + + result = ::Achievements::CreateService.new(namespace: namespace, + current_user: current_user, + params: args).execute + { achievement: result.payload, errors: result.errors } + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Namespace) + end + end + end +end diff --git a/app/graphql/types/achievements/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb new file mode 100644 index 0000000000000..e2b9495c83dce --- /dev/null +++ b/app/graphql/types/achievements/achievement_type.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Types + module Achievements + class AchievementType < BaseObject + graphql_name 'Achievement' + + authorize :read_achievement + + field :id, + ::Types::GlobalIDType[::Achievements::Achievement], + null: false, + description: 'ID of the achievement.' + + field :namespace, + ::Types::NamespaceType, + null: false, + description: 'Namespace of the achievement.' + + field :name, + GraphQL::Types::String, + null: false, + description: 'Name of the achievement.' + + field :avatar_url, + GraphQL::Types::String, + null: true, + description: 'URL to avatar of the achievement.' + + field :description, + GraphQL::Types::String, + null: true, + description: 'Description or notes for the achievement.' + + field :revokeable, + GraphQL::Types::Boolean, + null: false, + description: 'Revokeability of the achievement.' + + field :created_at, + Types::TimeType, + null: false, + description: 'Timestamp the achievement was created.' + + field :updated_at, + Types::TimeType, + null: false, + description: 'Timestamp the achievement was last updated.' + + def avatar_url + object.avatar_url(only_path: false) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index b342e57804b53..ae44035877e8d 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -6,6 +6,7 @@ class MutationType < BaseObject include Gitlab::Graphql::MountMutation + mount_mutation Mutations::Achievements::Create mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs mount_mutation Mutations::AlertManagement::CreateAlertIssue mount_mutation Mutations::AlertManagement::UpdateAlertStatus diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 0f634e7c2d346..fc55ff512b6d5 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -63,6 +63,13 @@ class NamespaceType < BaseObject description: "Timelog categories for the namespace.", alpha: { milestone: '15.3' } + field :achievements, + Types::Achievements::AchievementType.connection_type, + null: true, + alpha: { milestone: '15.8' }, + description: "Achievements for the namespace. " \ + "Returns `null` if the `achievements` feature flag is disabled." + markdown_field :description_html, null: true def timelog_categories @@ -76,6 +83,10 @@ def cross_project_pipeline_available? def root_storage_statistics Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find end + + def achievements + object.achievements if Feature.enabled?(:achievements, object) + end end end diff --git a/app/policies/achievements/achievement_policy.rb b/app/policies/achievements/achievement_policy.rb new file mode 100644 index 0000000000000..9723be0196db9 --- /dev/null +++ b/app/policies/achievements/achievement_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Achievements + class AchievementPolicy < ::BasePolicy + delegate { @subject.namespace } + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 8eea995529c77..b2325b7acacf2 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -126,6 +126,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_group_member enable :read_custom_emoji enable :read_counts + enable :read_achievement end rule { ~public_group & ~has_access }.prevent :read_counts @@ -185,6 +186,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :maintainer_access enable :read_upload enable :destroy_upload + enable :admin_achievement end rule { owner }.policy do diff --git a/app/services/achievements/base_service.rb b/app/services/achievements/base_service.rb new file mode 100644 index 0000000000000..0a8e6ee3c78ef --- /dev/null +++ b/app/services/achievements/base_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Achievements + class BaseService < ::BaseContainerService + def initialize(namespace:, current_user: nil, params: {}) + @namespace = namespace + super(container: namespace, current_user: current_user, params: params) + end + + private + + def allowed? + current_user&.can?(:admin_achievement, @namespace) + end + + def error(message) + ServiceResponse.error(message: Array(message)) + end + end +end diff --git a/app/services/achievements/create_service.rb b/app/services/achievements/create_service.rb new file mode 100644 index 0000000000000..2843df6c19153 --- /dev/null +++ b/app/services/achievements/create_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Achievements + class CreateService < BaseService + def execute + return error_no_permissions unless allowed? + + achievement = Achievements::Achievement.create(params.merge(namespace_id: @namespace.id)) + + return error_creating(achievement) unless achievement.persisted? + + ServiceResponse.success(payload: achievement) + end + + private + + def error_no_permissions + error('You have insufficient permissions to create achievements for this namespace') + end + + def error_creating(achievement) + error(achievement&.errors&.full_messages || 'Failed to create achievement') + end + end +end diff --git a/config/feature_flags/development/achievements.yml b/config/feature_flags/development/achievements.yml new file mode 100644 index 0000000000000..853a8133351a9 --- /dev/null +++ b/config/feature_flags/development/achievements.yml @@ -0,0 +1,8 @@ +--- +name: achievements +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106909 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/386817 +milestone: '15.8' +type: development +group: group::organization +default_enabled: false diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb index ff4c11e805acc..52c67a705dc09 100644 --- a/config/routes/uploads.rb +++ b/config/routes/uploads.rb @@ -4,7 +4,7 @@ # Note attachments and User/Group/Project/Topic avatars get "-/system/:model/:mounted_as/:id/:filename", to: "uploads#show", - constraints: { model: %r{note|user|group|project|projects\/topic}, mounted_as: /avatar|attachment/, filename: %r{[^/]+} } + constraints: { model: %r{note|user|group|project|projects\/topic|achievements\/achievement}, mounted_as: /avatar|attachment/, filename: %r{[^/]+} } # show uploads for models, snippets (notes) available for now get '-/system/:model/:id/:secret/:filename', diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index f8dfc8176efd7..a893b24548016 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -680,6 +680,29 @@ mutation($id: NoteableID!, $body: String!) { } ``` +### `Mutation.achievementsCreate` + +Input type: `AchievementsCreateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationachievementscreateavatar"></a>`avatar` | [`Upload`](#upload) | Avatar for the achievement. | +| <a id="mutationachievementscreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationachievementscreatedescription"></a>`description` | [`String`](#string) | Description of or notes for the achievement. | +| <a id="mutationachievementscreatename"></a>`name` | [`String!`](#string) | Name for the achievement. | +| <a id="mutationachievementscreatenamespaceid"></a>`namespaceId` | [`NamespaceID!`](#namespaceid) | Namespace for the achievement. | +| <a id="mutationachievementscreaterevokeable"></a>`revokeable` | [`Boolean!`](#boolean) | Revokeability for the achievement. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationachievementscreateachievement"></a>`achievement` | [`Achievement`](#achievement) | Achievement created. | +| <a id="mutationachievementscreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationachievementscreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.addProjectToSecurityDashboard` Input type: `AddProjectToSecurityDashboardInput` @@ -6218,6 +6241,29 @@ Some of the types in the schema exist solely to model connections. Each connecti has a distinct, named type, with a distinct named edge type. These are listed separately below. +#### `AchievementConnection` + +The connection type for [`Achievement`](#achievement). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="achievementconnectionedges"></a>`edges` | [`[AchievementEdge]`](#achievementedge) | A list of edges. | +| <a id="achievementconnectionnodes"></a>`nodes` | [`[Achievement]`](#achievement) | A list of nodes. | +| <a id="achievementconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `AchievementEdge` + +The edge type for [`Achievement`](#achievement). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="achievementedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="achievementedgenode"></a>`node` | [`Achievement`](#achievement) | The item at the end of the edge. | + #### `AgentConfigurationConnection` The connection type for [`AgentConfiguration`](#agentconfiguration). @@ -10251,6 +10297,21 @@ Representation of a GitLab user. | <a id="accessleveluserwebpath"></a>`webPath` | [`String!`](#string) | Web path of the user. | | <a id="accessleveluserweburl"></a>`webUrl` | [`String!`](#string) | Web URL of the user. | +### `Achievement` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="achievementavatarurl"></a>`avatarUrl` | [`String`](#string) | URL to avatar of the achievement. | +| <a id="achievementcreatedat"></a>`createdAt` | [`Time!`](#time) | Timestamp the achievement was created. | +| <a id="achievementdescription"></a>`description` | [`String`](#string) | Description or notes for the achievement. | +| <a id="achievementid"></a>`id` | [`AchievementsAchievementID!`](#achievementsachievementid) | ID of the achievement. | +| <a id="achievementname"></a>`name` | [`String!`](#string) | Name of the achievement. | +| <a id="achievementnamespace"></a>`namespace` | [`Namespace!`](#namespace) | Namespace of the achievement. | +| <a id="achievementrevokeable"></a>`revokeable` | [`Boolean!`](#boolean) | Revokeability of the achievement. | +| <a id="achievementupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp the achievement was last updated. | + ### `AgentConfiguration` Configuration details for an Agent. @@ -13517,6 +13578,7 @@ GPG signature for a signed commit. | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="groupachievements"></a>`achievements` **{warning-solid}** | [`AchievementConnection`](#achievementconnection) | **Introduced** in 15.8. This feature is in Alpha. It can be changed or removed at any time. Achievements for the namespace. Returns `null` if the `achievements` feature flag is disabled. | | <a id="groupactualrepositorysizelimit"></a>`actualRepositorySizeLimit` | [`Float`](#float) | Size limit for repositories in the namespace in bytes. | | <a id="groupadditionalpurchasedstoragesize"></a>`additionalPurchasedStorageSize` | [`Float`](#float) | Additional storage purchased for the root namespace in bytes. | | <a id="groupallowstalerunnerpruning"></a>`allowStaleRunnerPruning` | [`Boolean!`](#boolean) | Indicates whether to regularly prune stale group runners. Defaults to false. | @@ -16273,6 +16335,7 @@ Contains statistics about a milestone. | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="namespaceachievements"></a>`achievements` **{warning-solid}** | [`AchievementConnection`](#achievementconnection) | **Introduced** in 15.8. This feature is in Alpha. It can be changed or removed at any time. Achievements for the namespace. Returns `null` if the `achievements` feature flag is disabled. | | <a id="namespaceactualrepositorysizelimit"></a>`actualRepositorySizeLimit` | [`Float`](#float) | Size limit for repositories in the namespace in bytes. | | <a id="namespaceadditionalpurchasedstoragesize"></a>`additionalPurchasedStorageSize` | [`Float`](#float) | Additional storage purchased for the root namespace in bytes. | | <a id="namespacecontainslockedprojects"></a>`containsLockedProjects` | [`Boolean!`](#boolean) | Includes at least one project where the repository size exceeds the limit. | @@ -23284,6 +23347,12 @@ each kind of object. For more information, read about [Scalar Types](https://graphql.org/learn/schema/#scalar-types) on `graphql.org`. +### `AchievementsAchievementID` + +A `AchievementsAchievementID` is a global ID. It is encoded as a string. + +An example `AchievementsAchievementID` is: `"gid://gitlab/Achievements::Achievement/1"`. + ### `AlertManagementAlertID` A `AlertManagementAlertID` is a global ID. It is encoded as a string. diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 4f2d77d765261..8015136d1e033 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -736,6 +736,46 @@ expect(response).to have_gitlab_http_status(:ok) end end + + context "when viewing an achievement" do + let!(:achievement) { create(:achievement, avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) } + + context "when signed in" do + before do + sign_in(user) + end + + it "responds with status 200" do + get :show, params: { model: "achievements/achievement", mounted_as: "avatar", id: achievement.id, filename: "dk.png" } + + expect(response).to have_gitlab_http_status(:ok) + end + + it_behaves_like 'content publicly cached' do + subject do + get :show, params: { model: "achievements/achievement", mounted_as: "avatar", id: achievement.id, filename: "dk.png" } + + response + end + end + end + + context "when not signed in" do + it "responds with status 200" do + get :show, params: { model: "achievements/achievement", mounted_as: "avatar", id: achievement.id, filename: "dk.png" } + + expect(response).to have_gitlab_http_status(:ok) + end + + it_behaves_like 'content publicly cached' do + subject do + get :show, params: { model: "achievements/achievement", mounted_as: "avatar", id: achievement.id, filename: "dk.png" } + + response + end + end + end + end end def post_authorize(verified: true) diff --git a/spec/graphql/mutations/achievements/create_spec.rb b/spec/graphql/mutations/achievements/create_spec.rb new file mode 100644 index 0000000000000..4bad61643141d --- /dev/null +++ b/spec/graphql/mutations/achievements/create_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Create, feature_category: :users do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + + let(:group) { create(:group) } + let(:valid_params) do + attributes_for(:achievement, namespace: group) + end + + describe '#resolve' do + subject(:resolve_mutation) do + described_class.new(object: nil, context: { current_user: user }, field: nil).resolve( + **valid_params, + namespace_id: group.to_global_id + ) + end + + context 'when the user does not have permission' do + before do + group.add_developer(user) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) + end + end + + context 'when the user has permission' do + before do + group.add_maintainer(user) + end + + context 'when the params are invalid' do + it 'returns the validation error' do + valid_params[:name] = nil + + expect(resolve_mutation[:errors]).to match_array(["Name can't be blank"]) + end + end + + it 'creates contact with correct values' do + expect(resolve_mutation[:achievement]).to have_attributes(valid_params) + end + end + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_achievement) } +end diff --git a/spec/graphql/types/achievements/achievement_type_spec.rb b/spec/graphql/types/achievements/achievement_type_spec.rb new file mode 100644 index 0000000000000..5c98753ac66cf --- /dev/null +++ b/spec/graphql/types/achievements/achievement_type_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['Achievement'], feature_category: :users do + include GraphqlHelpers + + let(:fields) do + %w[ + id + namespace + name + avatar_url + description + revokeable + created_at + updated_at + ] + end + + it { expect(described_class.graphql_name).to eq('Achievement') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_achievement) } + + describe '#avatar_url' do + let(:object) { instance_double(Achievements::Achievement) } + let(:current_user) { instance_double(User) } + + before do + allow(described_class).to receive(:authorized?).and_return(true) + end + + it 'calls Achievement#avatar_url(only_path: false)' do + allow(object).to receive(:avatar_url).with(only_path: false) + resolve_field(:avatar_url, object, current_user: current_user) + expect(object).to have_received(:avatar_url).with(only_path: false).once + end + end +end diff --git a/spec/graphql/types/namespace_type_spec.rb b/spec/graphql/types/namespace_type_spec.rb index 168a6ba4eaa29..d80235023ef3e 100644 --- a/spec/graphql/types/namespace_type_spec.rb +++ b/spec/graphql/types/namespace_type_spec.rb @@ -9,7 +9,7 @@ expected_fields = %w[ id name path full_name full_path description description_html visibility lfs_enabled request_access_enabled projects root_storage_statistics shared_runners_setting - timelog_categories + timelog_categories achievements ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/requests/api/graphql/mutations/achievements/create_spec.rb b/spec/requests/api/graphql/mutations/achievements/create_spec.rb new file mode 100644 index 0000000000000..1713f050540c0 --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/create_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Create, feature_category: :users do + include GraphqlHelpers + include WorkhorseHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + + let(:mutation) { graphql_mutation(:achievements_create, params) } + let(:name) { 'Name' } + let(:description) { 'Description' } + let(:revokeable) { false } + let(:avatar) { fixture_file_upload("spec/fixtures/dk.png") } + let(:params) do + { + namespace_id: group.to_global_id, + name: name, + avatar: avatar, + description: description, + revokeable: revokeable + } + end + + subject { post_graphql_mutation_with_uploads(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_create) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + let(:avatar) {} + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not create an achievement' do + expect { subject }.not_to change { Achievements::Achievement.count } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:name) {} + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('provided invalid value for name (Expected value to not be null)') + end + end + + it 'creates an achievement' do + expect { subject }.to change { Achievements::Achievement.count }.by(1) + end + + it 'returns the new achievement' do + subject + + expect(graphql_data_at(:achievements_create, :achievement)).to match a_hash_including( + 'name' => name, + 'namespace' => a_hash_including('id' => group.to_global_id.to_s), + 'description' => description, + 'revokeable' => revokeable + ) + end + end +end diff --git a/spec/services/achievements/create_service_spec.rb b/spec/services/achievements/create_service_spec.rb new file mode 100644 index 0000000000000..f62a45deb5035 --- /dev/null +++ b/spec/services/achievements/create_service_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Achievements::CreateService, feature_category: :users do + describe '#execute' do + let_it_be(:user) { create(:user) } + + let(:params) { attributes_for(:achievement, namespace: group) } + + subject(:response) { described_class.new(namespace: group, current_user: user, params: params).execute } + + context 'when user does not have permission' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_developer(user) + end + + it 'returns an error' do + expect(response).to be_error + expect(response.message).to match_array( + ['You have insufficient permissions to create achievements for this namespace']) + end + end + + context 'when user has permission' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_maintainer(user) + end + + it 'creates an achievement' do + expect(response).to be_success + end + + it 'returns an error when the achievement is not persisted' do + params[:name] = nil + + expect(response).to be_error + expect(response.message).to match_array(["Name can't be blank"]) + end + end + end +end diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index f6ac98c766905..fddcecbe12533 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -12,7 +12,7 @@ let(:public_permissions) do %i[ - read_group read_counts + read_group read_counts read_achievement read_label read_issue_board_list read_milestone read_issue_board ] end @@ -57,6 +57,7 @@ create_projects create_cluster update_cluster admin_cluster add_cluster destroy_upload + admin_achievement ] end -- GitLab