Commit d047a1aa authored by Adam Hegyi's avatar Adam Hegyi Committed by Jan Provaznik

Service objects for CA group stages

This changes introduces service objects for creating, updating and
finding cycle analytics group stages.
parent 8e688e5b
......@@ -7,6 +7,7 @@ module Analytics
included do
validates :name, presence: true
validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom?
validates :start_event_identifier, presence: true
validates :end_event_identifier, presence: true
validate :validate_stage_event_pairs
......@@ -15,6 +16,7 @@ module Analytics
enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier
alias_attribute :custom_stage?, :custom
scope :default_stages, -> { where(custom: false) }
end
def parent=(_)
......
......@@ -31,7 +31,7 @@ module Analytics
end
def stage_list_service
Analytics::CycleAnalytics::StageListService.new(
Analytics::CycleAnalytics::Stages::ListService.new(
parent: @group,
current_user: current_user
)
......
# frozen_string_literal: true
module Analytics
module CycleAnalytics
class StageFinder
NUMBERS_ONLY = /\A\d+\z/.freeze
def initialize(parent:, stage_id:)
@parent = parent
@stage_id = stage_id
end
def execute
if in_memory_default_stage?
build_in_memory_stage_by_name
else
parent.cycle_analytics_stages.find(stage_id)
end
end
private
attr_reader :parent, :stage_id
def in_memory_default_stage?
!NUMBERS_ONLY.match?(stage_id.to_s)
end
def build_in_memory_stage_by_name
parent.cycle_analytics_stages.build(find_in_memory_stage)
end
def find_in_memory_stage
# raise ActiveRecord::RecordNotFound, so it will behave similarly to AR models and produce 404 response in the controller
raw_stage = Gitlab::Analytics::CycleAnalytics::DefaultStages.all.find do |hash|
hash[:name].eql?(stage_id)
end
raise(ActiveRecord::RecordNotFound, "Stage with id '#{stage_id}' could not be found") unless raw_stage
raw_stage
end
end
end
end
......@@ -69,8 +69,9 @@ module EE
rule { can?(:read_group) & contribution_analytics_available }
.enable :read_group_contribution_analytics
rule { reporter & cycle_analytics_available }
.enable :read_group_cycle_analytics
rule { reporter & cycle_analytics_available }.policy do
enable :read_group_cycle_analytics, :create_group_stage, :read_group_stage, :update_group_stage, :delete_group_stage
end
rule { can?(:read_group) & dependency_proxy_available }
.enable :read_dependency_proxy
......
......@@ -2,40 +2,53 @@
module Analytics
module CycleAnalytics
class StageListService
class BaseService
include Gitlab::Allowable
def initialize(parent:, current_user:)
def initialize(parent:, current_user:, params: {})
@parent = parent
@current_user = current_user
end
def execute
return forbidden unless allowed?
success(build_default_stages)
raise NotImplementedError
end
private
attr_reader :parent, :current_user
attr_reader :parent, :current_user, :params
def build_default_stages
Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |params|
parent.cycle_analytics_stages.build(params)
end
def success(stage, http_status = :created)
ServiceResponse.success(payload: { stage: stage }, http_status: http_status)
end
def error(stage)
ServiceResponse.error(message: 'Invalid parameters', payload: { errors: stage.errors }, http_status: :unprocessable_entity)
end
def success(stages)
ServiceResponse.success(payload: { stages: stages })
def not_found
ServiceResponse.error(message: 'Stage not found', payload: {}, http_status: :not_found)
end
def forbidden
ServiceResponse.error(message: 'Forbidden', http_status: :forbidden)
ServiceResponse.error(message: 'Forbidden', payload: {}, http_status: :forbidden)
end
def persist_default_stages!
persisted_default_stages = parent.cycle_analytics_stages.default_stages
# make sure that we persist default stages only once
stages_to_persist = build_default_stages.select do |new_default_stage|
!persisted_default_stages.find { |s| s.name.eql?(new_default_stage.name) }
end
stages_to_persist.each(&:save!)
end
def allowed?
can?(current_user, :read_group_cycle_analytics, parent)
def build_default_stages
Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |params|
parent.cycle_analytics_stages.build(params)
end
end
end
end
......
# frozen_string_literal: true
module Analytics
module CycleAnalytics
module Stages
class CreateService < BaseService
def initialize(parent:, current_user:, params:)
super
@stage = parent.cycle_analytics_stages.build(params)
end
def execute
return forbidden unless can?(current_user, :create_group_stage, parent)
return error(stage) unless stage.valid?
parent.class.transaction do
persist_default_stages!
stage.save!
end
success(stage)
end
private
attr_reader :stage
end
end
end
end
# frozen_string_literal: true
module Analytics
module CycleAnalytics
module Stages
class ListService < BaseService
def execute
return forbidden unless can?(current_user, :read_group_cycle_analytics, parent)
success(persisted_stages.presence || build_default_stages)
end
private
def success(stages)
ServiceResponse.success(payload: { stages: stages })
end
def persisted_stages
parent.cycle_analytics_stages
end
end
end
end
end
# frozen_string_literal: true
module Analytics
module CycleAnalytics
module Stages
class UpdateService < BaseService
def initialize(parent:, current_user:, params:)
super
@params = params
end
def execute
return forbidden unless can?(current_user, :update_group_stage, parent)
parent.cycle_analytics_stages.model.transaction do
persist_default_stages!
@stage = find_stage
@stage.assign_attributes(filtered_params)
raise ActiveRecord::Rollback unless @stage.valid?
@stage.save!
end
@stage.valid? ? success(@stage, :ok) : error(@stage)
end
private
def filtered_params
{}.tap do |new_params|
if default_stage?
new_params[:hidden] = params[:hidden] # for default stage only hidden parameter is allowed
else
new_params.merge!(params)
end
end.compact
end
def default_stage?
Gitlab::Analytics::CycleAnalytics::DefaultStages.names.include?(params[:id])
end
# rubocop: disable CodeReuse/ActiveRecord
def find_stage
if default_stage?
# default stages are already persisted
parent.cycle_analytics_stages.find_by!(name: params[:id])
else
parent.cycle_analytics_stages.find(params[:id])
end
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
end
......@@ -93,7 +93,7 @@ describe Analytics::CycleAnalytics::StagesController do
end
it 'renders 403 based on the response of the service object' do
expect_any_instance_of(Analytics::CycleAnalytics::StageListService).to receive(:allowed?).and_return(false)
expect_any_instance_of(Analytics::CycleAnalytics::Stages::ListService).to receive(:can?).and_return(false)
subject
......
# frozen_string_literal: true
FactoryBot.define do
factory :cycle_analytics_group_stage, class: Analytics::CycleAnalytics::GroupStage do
sequence(:name) { |n| "Stage ##{n}" }
start_event_identifier { Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestCreated.identifier }
end_event_identifier { Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged.identifier }
group
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::StageFinder do
let_it_be(:group) { create(:group) }
let(:stage_id) { { id: Gitlab::Analytics::CycleAnalytics::DefaultStages.names.first } }
subject { described_class.new(parent: group, stage_id: stage_id[:id]).execute }
context 'when looking up in-memory default stage by name exists' do
it { expect(subject).not_to be_persisted }
it { expect(subject.name).to eq(stage_id[:id]) }
end
context 'when in-memory default stage cannot be found' do
before do
stage_id[:id] = 'unknown_default_stage'
end
it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) }
end
context 'when persisted stage exists' do
let(:stage) { create(:cycle_analytics_group_stage, group: group) }
before do
stage_id[:id] = stage.id
end
it { expect(subject).to be_persisted }
it { expect(subject.name).to eq(stage.name) }
end
context 'when persisted stage cannot be found' do
before do
stage_id[:id] = -1
end
it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::StageListService do
let(:group) { create(:group) }
let(:user) { create(:user) }
subject { described_class.new(parent: group, current_user: user) }
context 'succeeds' do
let(:stages) { subject.execute.payload[:stages] }
before do
stub_licensed_features(cycle_analytics_for_groups: true)
group.add_reporter(user)
end
it 'returns only the default stages' do
expect(stages.size).to eq(Gitlab::Analytics::CycleAnalytics::DefaultStages.all.size)
end
it 'provides the default stages as non-persisted objects' do
stage_ids = stages.map(&:id)
expect(stage_ids.all?(&:nil?)).to eq(true)
end
end
it 'returns forbidden response' do
result = subject.execute
expect(result).to be_error
expect(result.http_status).to eq(:forbidden)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::Stages::CreateService do
let_it_be(:group, refind: true) { create(:group) }
let_it_be(:user, refind: true) { create(:user) }
let(:params) { { name: 'my stage', start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged } }
before_all do
group.add_user(user, :reporter)
end
before do
stub_licensed_features(cycle_analytics_for_groups: true)
end
subject { described_class.new(parent: group, params: params, current_user: user).execute }
it_behaves_like 'permission check for cycle analytics stage services', :cycle_analytics_for_groups
describe 'custom stage creation' do
context 'when service response is successful' do
let(:stage) { subject.payload[:stage] }
it { expect(subject).to be_success }
it { expect(subject.http_status).to eq(:created) }
it { expect(stage).to be_present }
it { expect(stage).to be_persisted }
it { expect(stage.start_event_identifier).to eq(params[:start_event_identifier].to_s) }
it { expect(stage.end_event_identifier).to eq(params[:end_event_identifier].to_s) }
end
end
context 'when params are invalid' do
before do
params.delete(:name)
end
it { expect(subject).to be_error }
it { expect(subject.http_status).to eq(:unprocessable_entity) }
it { expect(subject.payload[:errors].keys).to eq([:name]) }
end
describe 'persistence of default stages' do
let(:persisted_stages) { group.cycle_analytics_stages }
let(:customized_stages) { group.cycle_analytics_stages.where(custom: true) }
let(:default_stages) { Gitlab::Analytics::CycleAnalytics::DefaultStages.all }
let(:expected_stage_count) { default_stages.count + customized_stages.count }
context 'when creating custom stages' do
it { expect(subject).to be_success }
it 'persists all default stages' do
subject
expect(persisted_stages.count).to eq(expected_stage_count)
end
context 'when creating two custom stages' do
before do
described_class.new(parent: group, params: params.merge(name: 'other stage'), current_user: user).execute
end
it 'creates two customized stages' do
subject
expect(customized_stages.count).to eq(2)
end
it 'creates records for the default stages only once plus two customized stage records' do
expect(group.cycle_analytics_stages.count).to eq(expected_stage_count)
end
end
end
context 'when params are invalid' do
before do
params.delete(:name)
end
it { expect(subject).to be_error }
it 'skips persisting default stages on validation error' do
expect(group.cycle_analytics_stages.count).to eq(0)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::Stages::ListService do
let_it_be(:group, refind: true) { create(:group) }
let_it_be(:user) { create(:user) }
let(:stages) { subject.payload[:stages] }
subject { described_class.new(parent: group, current_user: user).execute }
before_all do
group.add_reporter(user)
end
before do
stub_licensed_features(cycle_analytics_for_groups: true)
end
it_behaves_like 'permission check for cycle analytics stage services', :cycle_analytics_for_groups
it 'returns only the default stages' do
expect(stages.size).to eq(Gitlab::Analytics::CycleAnalytics::DefaultStages.all.size)
end
it 'provides the default stages as non-persisted objects' do
expect(stages.map(&:id)).to all(be_nil)
end
context 'when there are persisted stages' do
let!(:stage1) { create(:cycle_analytics_group_stage, parent: group) }
let!(:stage2) { create(:cycle_analytics_group_stage, parent: group) }
it 'returns the persisted stages' do
expect(stages).to contain_exactly(stage1, stage2)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::Stages::UpdateService do
let_it_be(:group, refind: true) { create(:group) }
let_it_be(:user, refind: true) { create(:user) }
let(:default_stages) { Gitlab::Analytics::CycleAnalytics::DefaultStages.all }
let(:params) { {} }
subject { described_class.new(parent: group, params: params, current_user: user).execute }
before_all do
group.add_user(user, :reporter)
end
before do
stub_licensed_features(cycle_analytics_for_groups: true)
end
it_behaves_like 'permission check for cycle analytics stage services', :cycle_analytics_for_groups
context 'when updating a default stage' do
let(:stage) { Analytics::CycleAnalytics::GroupStage.new(default_stages.first.merge(group: group)) }
let(:params) { { id: stage.name, hidden: true } }
let(:updated_stage) { subject.payload[:stage] }
context 'when hiding a default stage' do
it { expect(subject).to be_success }
it { expect(updated_stage).to be_persisted }
it { expect(updated_stage).to be_hidden }
end
context 'when other parameters than "hidden" are given' do
before do
params[:name] = 'should not be updated'
end
it { expect(subject).to be_success }
it { expect(updated_stage.name).not_to eq(params[:name]) }
end
context 'when the first update happens on a default stage' do
let(:persisted_stages) { group.reload.cycle_analytics_stages }
it { expect(subject).to be_success }
it 'persists all default stages' do
subject
expect(persisted_stages.count).to eq(default_stages.count)
expect(persisted_stages).to all(be_persisted)
end
it 'matches with the configured default stage name' do
subject
default_stage_names = default_stages.map { |s| s[:name] }
expect(default_stage_names).to include(updated_stage.name)
end
context 'when the update fails' do
before do
invalid_stage = Analytics::CycleAnalytics::GroupStage.new(name: '')
expect_any_instance_of(described_class).to receive(:find_stage).and_return(invalid_stage)
end
it 'returns unsuccessful service response' do
subject
expect(subject).not_to be_success
end
it 'does not persist the default stages if the stage is invalid' do
subject
expect(persisted_stages).not_to include(be_persisted)
end
end
end
context 'when updating an already persisted default stage' do
let(:persisted_stage) { subject.payload[:stage] }
let(:updated_stage) do
described_class
.new(parent: group, params: { id: persisted_stage.id, hidden: false }, current_user: user)
.execute
.payload[:stage]
end
it { expect(updated_stage).to be_persisted }
it { expect(updated_stage).not_to be_hidden }
end
end
context 'when updating a custom stage' do
let_it_be(:stage) { create(:cycle_analytics_group_stage, group: group) }
let(:params) { { id: stage.id, name: 'my new stage name' } }
it { expect(subject).to be_success }
it { expect(subject.http_status).to eq(:ok) }
it { expect(subject.payload[:stage].name).to eq(params[:name]) }
context 'when params are invalid' do
before do
params[:name] = ''
end
it { expect(subject).to be_error }
it { expect(subject.http_status).to eq(:unprocessable_entity) }
it { expect(subject.payload[:errors].keys).to eq([:name]) }
end
end
end
# frozen_string_literal: true
shared_examples 'permission check for cycle analytics stage services' do |required_license|
context 'when user has no access' do
before do
group.add_user(user, :guest)
end
it { expect(subject).to be_error }
it { expect(subject.http_status).to eq(:forbidden) }
end
context 'when license is missing' do
before do
stub_licensed_features(required_license => false)
end
it { expect(subject).to be_error }
it { expect(subject.http_status).to eq(:forbidden) }
end
end
......@@ -23,6 +23,10 @@ module Gitlab
]
end
def self.names
all.map { |stage| stage[:name] }
end
def self.params_for_issue_stage
{
name: 'issue',
......
......@@ -46,6 +46,13 @@ shared_examples_for 'cycle analytics stage' do
expect(stage).not_to be_valid
expect(stage.errors.details[:end_event]).to eq([{ error: :not_allowed_for_the_given_start_event }])
end
context 'disallows default stage names when creating custom stage' do
let(:invalid_params) { valid_params.merge(name: Gitlab::Analytics::CycleAnalytics::DefaultStages.names.first, custom: true) }
let(:stage) { described_class.new(invalid_params) }
it { expect(stage).not_to be_valid }
end
end
describe '#subject_model' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment