Commit 3156b6ca authored by Mark Chao's avatar Mark Chao

Merge branch 'jh-add_seats_overage_notification' into 'master'

Trigger an email when seat overage occurs

See merge request gitlab-org/gitlab!79807
parents 9ff52fe2 5b32ac5c
# frozen_string_literal: true
module GitlabSubscriptions
class NotifySeatsExceededService
attr_reader :namespace
def initialize(namespace)
@namespace = namespace
end
def execute
return error('Namespace is not a top level group') if namespace.subgroup?
return error('No subscription found for namespace') if subscription.nil?
subscription.refresh_seat_attributes!
return error('No seat overage') unless subscription.seats_owed > 0
notify_users!
ServiceResponse.success(message: 'Overage notification sent')
end
private
def subscription
namespace.gitlab_subscription
end
def notify_users!
Gitlab::SubscriptionPortal::Client.send_seat_overage_notification(
group: namespace,
max_seats_used: subscription.max_seats_used
)
end
def error(message)
ServiceResponse.error(message: message)
end
end
end
......@@ -12,7 +12,18 @@ module GitlabSubscriptions
worker_has_external_dependencies!
def handle_event(event)
# no-op for now, to be implemented in https://gitlab.com/gitlab-org/gitlab/-/issues/348487
source = case event.data[:source_type]
when 'Group'
Group.find_by_id(event.data[:source_id])
when 'Project'
Project.find_by_id(event.data[:source_id])
else
nil
end
return unless source&.root_ancestor.present?
GitlabSubscriptions::NotifySeatsExceededService.new(source.root_ancestor).execute
end
end
end
......@@ -7,6 +7,14 @@ module Gitlab
extend ActiveSupport::Concern
CONNECTIVITY_ERROR = 'CONNECTIVITY_ERROR'
RESCUABLE_HTTP_ERRORS = [
Gitlab::HTTP::BlockedUrlError,
HTTParty::Error,
Errno::ECONNREFUSED,
Errno::ECONNRESET,
SocketError,
Timeout::Error
].freeze
class_methods do
def activate(activation_code)
......@@ -44,7 +52,7 @@ module Gitlab
else
error(response['errors'])
end
rescue Gitlab::HTTP::BlockedUrlError, HTTParty::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, Timeout::Error => e
rescue *RESCUABLE_HTTP_ERRORS => e
Gitlab::ErrorTracking.log_exception(e)
error(CONNECTIVITY_ERROR)
end
......@@ -183,14 +191,41 @@ module Gitlab
{ query: query, variables: variables }
)
return error(CONNECTIVITY_ERROR) unless response[:success]
parse_errors(response, query_name: 'orderNamespaceNameUpdate').presence || { success: true }
rescue *RESCUABLE_HTTP_ERRORS => e
Gitlab::ErrorTracking.log_exception(e)
errors = response.dig(:data, 'errors') ||
response.dig(:data, 'data', 'orderNamespaceNameUpdate', 'errors')
error(CONNECTIVITY_ERROR)
end
errors.blank? ? { success: true } : error(errors)
rescue Gitlab::HTTP::BlockedUrlError, HTTParty::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, Timeout::Error => e
def send_seat_overage_notification(group:, max_seats_used:)
query = <<~GQL
mutation($namespaceId: Int!, $maxSeatsUsed: Int!, $groupOwners: [GitlabEmailsUserInput!]!) {
sendSeatOverageNotificationEmail(input: {
glNamespaceId: $namespaceId,
maxSeatsUsed: $maxSeatsUsed,
groupOwners: $groupOwners
}) {
errors
}
}
GQL
owners_data = group.owners.map do |owner|
{ id: owner.id, email: owner.notification_email_for(group), fullName: owner.name }
end
response = execute_graphql_query(
{
query: query,
variables: { namespaceId: group.id, maxSeatsUsed: max_seats_used, groupOwners: owners_data }
}
)
parse_errors(response, query_name: 'sendSeatOverageNotificationEmail').presence || { success: true }
rescue *RESCUABLE_HTTP_ERRORS => e
Gitlab::ErrorTracking.log_exception(e)
error(CONNECTIVITY_ERROR)
end
......@@ -218,6 +253,19 @@ module Gitlab
)
end
def parse_errors(response, query_name: nil)
return error(CONNECTIVITY_ERROR) unless response[:success]
errors = [
response.dig(:data, 'errors'),
response.dig(:data, 'data', query_name, 'errors')
]
errors = errors.flat_map(&:presence).compact
error(errors) if errors.any?
end
def error(errors = nil)
{
success: false,
......
......@@ -455,4 +455,114 @@ RSpec.describe Gitlab::SubscriptionPortal::Clients::Graphql do
expect(Gitlab::ErrorTracking).to have_received(:log_exception).with(kind_of(Timeout::Error))
end
end
describe '#send_seat_overage_notification' do
context 'when the subscription portal response is successful' do
it 'returns successfully' do
group = create(:group)
owner_1 = create(:user)
owner_2 = create(:user)
group.add_owner(owner_1)
group.add_owner(owner_2)
expected_query_params = {
variables: {
namespaceId: group.id,
maxSeatsUsed: 10,
groupOwners: [
{ id: owner_1.id, email: owner_1.email, fullName: owner_1.name },
{ id: owner_2.id, email: owner_2.email, fullName: owner_2.name }
]
},
query: <<~GQL
mutation($namespaceId: Int!, $maxSeatsUsed: Int!, $groupOwners: [GitlabEmailsUserInput!]!) {
sendSeatOverageNotificationEmail(input: {
glNamespaceId: $namespaceId,
maxSeatsUsed: $maxSeatsUsed,
groupOwners: $groupOwners
}) {
errors
}
}
GQL
}
portal_response = {
success: true,
data: {
"data" => {
"sendSeatOverageNotificationEmail" => {
"errors" => []
}
}
}
}
expect(client).to receive(:execute_graphql_query).with(expected_query_params).and_return(portal_response)
request = client.send_seat_overage_notification(
group: group,
max_seats_used: 10
)
expect(request).to eq({ success: true })
end
end
context 'when the subscription portal response is unsuccessful' do
it 'returns an error response' do
expected_query_params = {
variables: { namespaceId: 1, maxSeatsUsed: nil, groupOwners: [] },
query: <<~GQL
mutation($namespaceId: Int!, $maxSeatsUsed: Int!, $groupOwners: [GitlabEmailsUserInput!]!) {
sendSeatOverageNotificationEmail(input: {
glNamespaceId: $namespaceId,
maxSeatsUsed: $maxSeatsUsed,
groupOwners: $groupOwners
}) {
errors
}
}
GQL
}
portal_response = {
success: true,
data: {
"errors" => [
{
"message" => "Argument 'maxSeatsUsed' on InputObject 'SendSeatOverageNotificationEmailInput' has an invalid value (null). Expected type 'Int!'.",
"locations" => [{ "line": 2, "column": 43 }],
"path" => %w[mutation sendSeatOverageNotificationEmail input maxSeatsUsed],
"extensions" => {
"code" => "argumentLiteralsIncompatible",
"typeName" => "InputObject",
"argumentName" => "maxSeatsUsed"
}
}
]
}
}
expect(client).to receive(:execute_graphql_query).with(expected_query_params).and_return(portal_response)
request = client.send_seat_overage_notification(group: build(:group, id: 1), max_seats_used: nil)
expect(request[:success]).to be false
expect(request[:errors]).not_to be_empty
end
end
context 'when there is a network connectivity error' do
it 'returns an error response' do
allow(client).to receive(:execute_graphql_query).and_raise(HTTParty::Error)
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(kind_of(HTTParty::Error))
request = client.send_seat_overage_notification(group: build(:group), max_seats_used: nil)
expect(request).to eq({ success: false, errors: "CONNECTIVITY_ERROR" })
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSubscriptions::NotifySeatsExceededService, :saas do
describe '#execute' do
context 'when the supplied group is a subgroup' do
it 'returns the relevant error response' do
group = build(:group, :nested)
expect(described_class.new(group).execute)
.to have_attributes(status: :error, message: 'Namespace is not a top level group')
end
end
context 'when the supplied group does not have a subscription' do
it 'returns the relevant error response' do
group = build(:group)
expect(described_class.new(group).execute)
.to have_attributes(status: :error, message: 'No subscription found for namespace')
end
end
context 'when the group has not exceeded the purchased seats' do
it 'returns the relevant error response' do
group = create(:group)
create(:gitlab_subscription, namespace: group)
expect(described_class.new(group).execute)
.to have_attributes(status: :error, message: 'No seat overage')
end
end
context 'when the top level group has exceeded its purchased seats' do
let_it_be(:group) { create(:group) }
let_it_be(:owner_1) { create(:user) }
let_it_be(:owner_2) { create(:user) }
before do
create(:gitlab_subscription, namespace: group, seats: 1)
group.add_owner(owner_1)
group.add_developer(create(:user))
group.add_owner(owner_2)
end
it 'triggers an email to each group owner and returns successfully' do
expect(Gitlab::SubscriptionPortal::Client)
.to receive(:send_seat_overage_notification)
.with(group: group, max_seats_used: 3)
.and_return({ success: true })
expect(described_class.new(group).execute)
.to have_attributes(status: :success, message: 'Overage notification sent')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSubscriptions::NotifySeatsExceededWorker do
describe '#handle_event' do
let_it_be(:group) { create(:group) }
context 'when the event source is unrecognized' do
it 'does not call the notification service' do
namespace = create(:namespace)
event = Members::MembersAddedEvent.new(data: {
source_id: namespace.id,
source_type: 'Namespace'
})
expect(GitlabSubscriptions::NotifySeatsExceededService).not_to receive(:new)
expect(described_class.new.handle_event(event)).to be_nil
end
end
context 'when the event source is a project' do
it 'calls the service with the root ancestor group' do
project = create(:project, namespace: group)
event = Members::MembersAddedEvent.new(data: {
source_id: project.id,
source_type: 'Project'
})
expect(GitlabSubscriptions::NotifySeatsExceededService)
.to receive(:new)
.with(group)
.and_call_original
described_class.new.handle_event(event)
end
end
context 'when the project cannot be found' do
it 'returns nil without calling the notification service' do
event = Members::MembersAddedEvent.new(data: {
source_id: 0,
source_type: 'Project'
})
expect(GitlabSubscriptions::NotifySeatsExceededService).not_to receive(:new)
expect(described_class.new.handle_event(event)).to be_nil
end
end
context 'when the group cannot be found' do
it 'returns nil without calling the notification service' do
event = Members::MembersAddedEvent.new(data: {
source_id: 0,
source_type: group.class.name
})
expect(GitlabSubscriptions::NotifySeatsExceededService).not_to receive(:new)
expect(described_class.new.handle_event(event)).to be_nil
end
end
context 'when supplied valid group data' do
it 'calls the notification service' do
event = Members::MembersAddedEvent.new(data: {
source_id: group.id,
source_type: group.class.name
})
expect(GitlabSubscriptions::NotifySeatsExceededService)
.to receive(:new)
.with(group)
.and_call_original
described_class.new.handle_event(event)
end
end
context 'when the group is a subgroup' do
it 'calls the notification service with the root ancestor' do
child_group = create(:group, parent: group)
event = Members::MembersAddedEvent.new(data: {
source_id: child_group.id,
source_type: child_group.class.name
})
expect(GitlabSubscriptions::NotifySeatsExceededService)
.to receive(:new)
.with(group)
.and_call_original
described_class.new.handle_event(event)
end
end
end
end
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