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