diff --git a/ee/app/models/gitlab_subscriptions/seat_assignment.rb b/ee/app/models/gitlab_subscriptions/seat_assignment.rb index 03a3b5b8f51798bb0528793b90b41a96216e2549..386813b59640bc325bf9605d0ff46e52fbf61279 100644 --- a/ee/app/models/gitlab_subscriptions/seat_assignment.rb +++ b/ee/app/models/gitlab_subscriptions/seat_assignment.rb @@ -6,5 +6,12 @@ class SeatAssignment < ApplicationRecord belongs_to :user, optional: false validates :namespace_id, uniqueness: { scope: :user_id } + + scope :by_namespace, ->(namespace) { where(namespace: namespace) } + scope :by_user, ->(user) { where(user: user) } + + def self.find_by_namespace_and_user(namespace, user) + by_namespace(namespace).by_user(user).first + end end end diff --git a/ee/app/services/gitlab_subscriptions/members/activity_service.rb b/ee/app/services/gitlab_subscriptions/members/activity_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..d2dcf427dce5c0945674fd3f7035df1994baa0be --- /dev/null +++ b/ee/app/services/gitlab_subscriptions/members/activity_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + module Members + class ActivityService + include ExclusiveLeaseGuard + + def initialize(user, namespace) + @user = user + @namespace = namespace&.root_ancestor + end + + def execute + return ServiceResponse.error(message: 'Invalid params') unless namespace&.group_namespace? && user + + try_obtain_lease do + if seat_assignment + seat_assignment.update!(last_activity_on: Time.current) + else + GitlabSubscriptions::SeatAssignment.create!( + namespace: namespace, user: user, last_activity_on: Time.current + ) + end + end + + ServiceResponse.success(message: 'Member activity tracked') + end + + private + + attr_reader :user, :namespace + + def lease_timeout + (Time.current.end_of_day - Time.current).to_i + end + + # Used by ExclusiveLeaseGuard + # do not update the record, if it has been already updated within the last lease_timeout + def lease_release? + false + end + + def lease_key + "gitlab_subscriptions:members_activity_event:#{namespace.id}:#{user.id}" + end + + def seat_assignment + @seat_assignment ||= GitlabSubscriptions::SeatAssignment.find_by_namespace_and_user(namespace, user) + end + end + end +end diff --git a/ee/spec/factories/gitlab_subscriptions/seat_assignments.rb b/ee/spec/factories/gitlab_subscriptions/seat_assignments.rb index 971e79c33c002ba316bd0efd3007f16508020dea..b7c88694f2a3de68171f1961ddfa101ececa3f7d 100644 --- a/ee/spec/factories/gitlab_subscriptions/seat_assignments.rb +++ b/ee/spec/factories/gitlab_subscriptions/seat_assignments.rb @@ -2,7 +2,15 @@ FactoryBot.define do factory :gitlab_subscription_seat_assignment, class: 'GitlabSubscriptions::SeatAssignment' do - namespace + namespace { association(:group) } user + + trait :active do + last_activity_on { 1.day.ago } + end + + trait :dormant do + last_activity_on { 91.days.ago } + end end end diff --git a/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb b/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb index 8bea9f0246bdd9a5044f215cf7b3840462696794..229648db49a11c3b2a96aeaaa6004101427bcf06 100644 --- a/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb +++ b/ee/spec/models/gitlab_subscriptions/seat_assignment_spec.rb @@ -3,6 +3,10 @@ require 'spec_helper' RSpec.describe GitlabSubscriptions::SeatAssignment, feature_category: :seat_cost_management do + let_it_be(:namespace) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:extra_dummy_record) { create(:gitlab_subscription_seat_assignment) } + subject { build(:gitlab_subscription_seat_assignment) } describe 'associations' do @@ -13,4 +17,30 @@ describe 'validations' do it { is_expected.to validate_uniqueness_of(:namespace_id).scoped_to(:user_id) } end + + describe 'scopes' do + describe '.by_namespace' do + it 'returns records filtered by namespace' do + result = create(:gitlab_subscription_seat_assignment, namespace: namespace) + + expect(described_class.by_namespace(namespace)).to match_array(result) + end + end + + describe '.by_user' do + it 'returns records filtered by namespace' do + result = create(:gitlab_subscription_seat_assignment, user: user) + + expect(described_class.by_user(user)).to match_array(result) + end + end + end + + describe '.find_by_namespace_and_user' do + it 'returns single record by namespace and user' do + result = create(:gitlab_subscription_seat_assignment, user: user, namespace: namespace) + + expect(described_class.find_by_namespace_and_user(namespace, user)).to eq(result) + end + end end diff --git a/ee/spec/services/gitlab_subscriptions/members/activity_service_spec.rb b/ee/spec/services/gitlab_subscriptions/members/activity_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ce4726029e55f1b642860d6b0563eba47c85ce5b --- /dev/null +++ b/ee/spec/services/gitlab_subscriptions/members/activity_service_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::Members::ActivityService, :clean_gitlab_redis_shared_state, feature_category: :seat_cost_management do + include ExclusiveLeaseHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:group) } + + let(:lease_key) { "gitlab_subscriptions:members_activity_event:#{namespace.id}:#{user.id}" } + let(:instance) { described_class.new(user, namespace) } + + describe '#execute' do + subject(:execute) { instance.execute } + + describe 'with valid params', :freeze_time do + it 'create a new seat assignment record' do + expect do + expect(execute).to be_success + end.to change { + GitlabSubscriptions::SeatAssignment.where( + namespace: namespace, user: user, last_activity_on: Time.current + ).count + } + end + + it 'updates the existing seat_assignment record' do + seat_assignment = create(:gitlab_subscription_seat_assignment, namespace: namespace, user: user) + + expect do + expect(execute).to be_success + end.to change { seat_assignment.reload.last_activity_on } + .from(nil).to(Time.current) + end + + context 'with project belonging to a group' do + let(:namespace) { build(:project, :in_group) } + + it 'returns success' do + expect do + expect(execute).to be_success + end.to change { GitlabSubscriptions::SeatAssignment.count }.by(1) + end + end + + it 'tries to obtain a lease' do + ttl = (Time.current.end_of_day - Time.current).to_i + expect_to_obtain_exclusive_lease(lease_key, timeout: ttl) + + expect(execute).to be_success + end + + context 'when a lease cannot be obtained' do + it 'returns success, without updating any record' do + stub_exclusive_lease_taken(lease_key) + + expect(instance).not_to receive(:seat_assignment) + + expect(execute).to be_success + end + end + end + + shared_examples 'returns an error' do + it do + response = execute + + expect(response).to be_error + expect(response.message).to eq('Invalid params') + end + end + + context 'with no namespace' do + let(:namespace) { nil } + + it_behaves_like 'returns an error' + end + + context 'with namespace not belonging to a group' do + let(:namespace) { create(:user_namespace) } + + it_behaves_like 'returns an error' + end + + context 'with no user' do + let(:user) { nil } + + it_behaves_like 'returns an error' + end + end +end