diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 7213bd074fce267adec2720033edd9f1a61c392c..af0f1bd680817e9e68aaf90b7b50bcf65b6dbcef 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -2,6 +2,7 @@ module EmailsHelper include AppearancesHelper + include SafeFormatHelper # Google Actions # https://developers.google.com/gmail/markup/reference/go-to-action @@ -236,6 +237,44 @@ def admin_changed_password_text(format: nil) end end + def member_about_to_expire_text(member_source, days_to_expire, format: nil) + days_formatted = pluralize(days_to_expire, 'day') + + case member_source + when Project + url = project_url(member_source) + when Group + url = group_url(member_source) + end + + case format + when :html + link_to = generate_link(member_source.human_name, url).html_safe + safe_format(_("Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}."), link_to: link_to, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted) + else + _("Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}.") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted } + end + end + + def member_about_to_expire_link(member, member_source, format: nil) + project_or_group = member_source.human_name + + case member_source + when Project + url = project_project_members_url(member_source, search: member.user.username) + when Group + url = group_group_members_url(member_source, search: member.user.username) + end + + case format + when :html + link_to = generate_link("#{member_source.class.name.downcase} membership", url).html_safe + safe_format(_('For additional information, review your %{link_to} or contact your %{project_or_group} owner.'), link_to: link_to, project_or_group: project_or_group) + else + _('For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner.') % { project_or_group: project_or_group, url: url } + end + end + def group_membership_expiration_changed_text(member, group) if member.expires? days = (member.expires_at - Date.today).to_i diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 33c955f94eeca92bb8a365368b5262b70b4c11ae..221d359c8c6f6f4121e8286144fd8d6e6a62d312 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -133,6 +133,22 @@ def member_expiration_date_updated_email(member_source_type, member_id) subject: subject(subject)) end + def member_about_to_expire_email(member_source_type, member_id) + @member_source_type = member_source_type + @member_id = member_id + + return unless member_exists? + return unless member.expires_at + + @days_to_expire = (member.expires_at - Date.today).to_i + + return if @days_to_expire <= 0 + + email_with_layout( + to: member.user.notification_email_for(notification_group), + subject: subject(s_("Your membership will expire in %{days_to_expire} days") % { days_to_expire: @days_to_expire })) + end + # rubocop: disable CodeReuse/ActiveRecord def member @member ||= Member.find_by(id: @member_id) diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 93d4625c34421186738da8adce70732db6e947a6..4c6ae930cc56870008930472e506191dec0d7bb9 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -166,6 +166,13 @@ def member_invited_email Notify.member_invited_email('project', member.id, '1234').message end + def member_about_to_expire_email + cleanup do + member = project.add_member(user, Gitlab::Access::GUEST, expires_at: 7.days.from_now.to_date) + Notify.member_about_to_expire_email('project', member.id).message + end + end + def pages_domain_enabled_email cleanup do pages_domain = PagesDomain.new(domain: 'my.example.com', project: project, verified_at: Time.now, enabled_until: 1.week.from_now) diff --git a/app/models/member.rb b/app/models/member.rb index f164ea244b4d525cf48bcb75ca26af74e8cb8caa..cdf40eaa8f5ddbf7b26d31d44ce7438820a3d848 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -153,6 +153,7 @@ class Member < ApplicationRecord scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) } scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) } scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) } + scope :expiring_and_not_notified, ->(date) { where("expiry_notified_at is null AND expires_at >= ? AND expires_at <= ?", Date.current, date) } scope :created_today, -> do now = Date.current diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index b2c0fffc12d42cc802221613626229485db3bfcb..3a3d0e53aae5b013bc76be99e5a114859c6e1d80 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -36,6 +36,7 @@ def update_member(member, permission) member.attributes = params return unless member.changed? + member.expiry_notified_at = nil if member.expires_at_changed? member.tap(&:save!) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index d305c8c03cfd4846bfaf25b9034a71e987431b98..ceafebddfcf62795d3e80cb5222d938051322a88 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -529,6 +529,12 @@ def update_project_member(project_member) mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end + def member_about_to_expire(member) + return true unless member.notifiable?(:mention) + + mailer.member_about_to_expire_email(member.real_source_type, member.id).deliver_later + end + # Group invite def invite_group_member(group_member, token) mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later diff --git a/app/views/notify/member_about_to_expire_email.html.haml b/app/views/notify/member_about_to_expire_email.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..a9f92d90ae639f9a4ac700df3751c6ab06e9164c --- /dev/null +++ b/app/views/notify/member_about_to_expire_email.html.haml @@ -0,0 +1,6 @@ += email_default_heading(say_hi(@member.user)) + +%p + = member_about_to_expire_text(@member_source, @days_to_expire, format: :html) +%p + = member_about_to_expire_link(@member, @member_source, format: :html) diff --git a/app/views/notify/member_about_to_expire_email.text.erb b/app/views/notify/member_about_to_expire_email.text.erb new file mode 100644 index 0000000000000000000000000000000000000000..0c6e78bf5017240237c60c78fe8b9104c1438c4f --- /dev/null +++ b/app/views/notify/member_about_to_expire_email.text.erb @@ -0,0 +1,5 @@ +<%= say_hi(@member.user) %> + +<%= member_about_to_expire_text(@member_source, @days_to_expire) %> + +<%= member_about_to_expire_link(@member, @member_source) %> diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 4f2d101239b4156a98ca5abf4728789f0f6fd670..e2d199b9e51e7e3323e95e88714e381b7e76a3f0 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -552,6 +552,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:members_expiring + :worker_name: Members::ExpiringWorker + :feature_category: :system_access + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: cronjob:metrics_global_metrics_update :worker_name: Metrics::GlobalMetricsUpdateWorker :feature_category: :metrics @@ -2955,6 +2964,15 @@ :weight: 2 :idempotent: :tags: [] +- :name: members_expiring_email_notification + :worker_name: Members::ExpiringEmailNotificationWorker + :feature_category: :system_access + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge :worker_name: MergeWorker :feature_category: :source_code_management diff --git a/app/workers/members/expiring_email_notification_worker.rb b/app/workers/members/expiring_email_notification_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..1d0a6eb254a0ea0076b7a4204504a423a0ae5af7 --- /dev/null +++ b/app/workers/members/expiring_email_notification_worker.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Members + class ExpiringEmailNotificationWorker # rubocop:disable Scalability/CronWorkerContext + include ApplicationWorker + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + feature_category :system_access + urgency :low + idempotent! + + def perform(member_id) + notification_service = NotificationService.new + member = ::Member.find_by_id(member_id) + + return unless member + return unless Feature.enabled?(:member_expiring_email_notification, member.source.root_ancestor) + return if member.expiry_notified_at.present? + + with_context(user: member.user) do + notification_service.member_about_to_expire(member) + Gitlab::AppLogger.info(message: "Notifying user about expiring membership", member_id: member.id) + + member.update(expiry_notified_at: Time.current) + end + end + end +end diff --git a/app/workers/members/expiring_worker.rb b/app/workers/members/expiring_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d631af3a7cc63eb6414652e7bdc172050c591bc --- /dev/null +++ b/app/workers/members/expiring_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Members + class ExpiringWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + data_consistency :sticky + feature_category :system_access + urgency :low + + BATCH_LIMIT = 500 + + def perform + return unless Feature.enabled?(:member_expiring_email_notification) + + limit_date = Member::DAYS_TO_EXPIRE.days.from_now.to_date + + expiring_members = Member.active.where(users: { user_type: :human }).expiring_and_not_notified(limit_date) # rubocop: disable CodeReuse/ActiveRecord + + expiring_members.each_batch(of: BATCH_LIMIT) do |members| + members.pluck_primary_key.each do |member_id| + Members::ExpiringEmailNotificationWorker.perform_async(member_id) + end + end + end + end +end diff --git a/config/feature_flags/development/member_expiring_email_notification.yml b/config/feature_flags/development/member_expiring_email_notification.yml new file mode 100644 index 0000000000000000000000000000000000000000..1775cc67b52f8b4d97219e2eb39a8e1f08f1bca0 --- /dev/null +++ b/config/feature_flags/development/member_expiring_email_notification.yml @@ -0,0 +1,8 @@ +--- +name: member_expiring_email_notification +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124577 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/416581 +milestone: '16.3' +type: development +group: group::authentication and authorization +default_enabled: false diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 50d26236a29e8b234a64009905c39702e11d97cd..cda625fab13ad1d82c930b5da5ac37eb68d73926 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -511,6 +511,9 @@ Settings.cron_jobs['ci_archive_traces_cron_worker'] ||= {} Settings.cron_jobs['ci_archive_traces_cron_worker']['cron'] ||= '17 * * * *' Settings.cron_jobs['ci_archive_traces_cron_worker']['job_class'] = 'Ci::ArchiveTracesCronWorker' +Settings.cron_jobs['members_expiring_worker'] ||= {} +Settings.cron_jobs['members_expiring_worker']['cron'] ||= '0 1 * * *' +Settings.cron_jobs['members_expiring_worker']['job_class'] = 'Members::ExpiringWorker' Settings.cron_jobs['remove_expired_members_worker'] ||= {} Settings.cron_jobs['remove_expired_members_worker']['cron'] ||= '10 0 * * *' Settings.cron_jobs['remove_expired_members_worker']['job_class'] = 'RemoveExpiredMembersWorker' diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 0205d24d48bd20a72fc70866a570a28242a37d08..7fe4db435a6d74df62c62afe31730eebfb011544 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -345,6 +345,8 @@ - 2 - - mailers - 2 +- - members_expiring_email_notification + - 1 - - merge - 5 - - merge_request_cleanup_refs diff --git a/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb b/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb new file mode 100644 index 0000000000000000000000000000000000000000..e890325e5fa3fe02ca26849394fca57a0048cccb --- /dev/null +++ b/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddExpiryNotifiedAtToMember < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + TABLE_NAME = 'members' + COLUMN_NAME = 'expiry_notified_at' + + def up + with_lock_retries do + add_column(TABLE_NAME, COLUMN_NAME, :datetime_with_timezone) + end + end + + def down + with_lock_retries do + remove_column TABLE_NAME, COLUMN_NAME + end + end +end diff --git a/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb b/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d98d4792af3936fffedc3906a967132c22f727a --- /dev/null +++ b/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddIndexForMemberExpiringQuery < Gitlab::Database::Migration[2.1] + INDEX_NAME = 'index_members_on_expiring_at_access_level_id' + + disable_ddl_transaction! + + def up + add_concurrent_index :members, + [:expires_at, :access_level, :id], + where: 'requested_at IS NULL AND expiry_notified_at IS NULL', + name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :members, INDEX_NAME + end +end diff --git a/db/schema_migrations/20230707003301 b/db/schema_migrations/20230707003301 new file mode 100644 index 0000000000000000000000000000000000000000..b1d5d84ee1bba2f8c936e4e9785e3bf26136e404 --- /dev/null +++ b/db/schema_migrations/20230707003301 @@ -0,0 +1 @@ +dac0b6b1f86685bd19aed528c75197adfee06154ea68efdb854f4b17deb973fa \ No newline at end of file diff --git a/db/schema_migrations/20230714015909 b/db/schema_migrations/20230714015909 new file mode 100644 index 0000000000000000000000000000000000000000..c5362b4a82d07265c250d1c7211b7382e8682ecc --- /dev/null +++ b/db/schema_migrations/20230714015909 @@ -0,0 +1 @@ +54f82e196c756ca60c4d7300ef1987a6b1ca50d4ef87bf89bb62eb577164365c \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d1f21674c21068702b896b695e8a935071e17075..18e82de6a74c55461eacedebf80882a8118e1957 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -18018,6 +18018,7 @@ CREATE TABLE members ( invite_email_success boolean DEFAULT true NOT NULL, member_namespace_id bigint, member_role_id bigint, + expiry_notified_at timestamp with time zone, CONSTRAINT check_508774aac0 CHECK ((member_namespace_id IS NOT NULL)) ); @@ -31872,6 +31873,8 @@ CREATE INDEX index_members_on_access_level ON members USING btree (access_level) CREATE INDEX index_members_on_expires_at ON members USING btree (expires_at); +CREATE INDEX index_members_on_expiring_at_access_level_id ON members USING btree (expires_at, access_level, id) WHERE ((requested_at IS NULL) AND (expiry_notified_at IS NULL)); + CREATE INDEX index_members_on_invite_email ON members USING btree (invite_email); CREATE UNIQUE INDEX index_members_on_invite_token ON members USING btree (invite_token); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 18019743fb568ffb5ef5e0c118150810146d25c3..d13b346d45032c0140955445b4ab24b86055cc43 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19827,9 +19827,15 @@ msgstr "" msgid "For a faster browsing experience, some files are collapsed by default." msgstr "" +msgid "For additional information, review your %{link_to} or contact your %{project_or_group} owner." +msgstr "" + msgid "For additional information, review your %{link_to} or contact your group owner." msgstr "" +msgid "For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner." +msgstr "" + msgid "For additional information, review your group membership: %{link_to} or contact your group owner." msgstr "" @@ -53886,6 +53892,15 @@ msgstr "" msgid "Your membership in %{group} no longer expires." msgstr "" +msgid "Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}." +msgstr "" + +msgid "Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}." +msgstr "" + +msgid "Your membership will expire in %{days_to_expire} days" +msgstr "" + msgid "Your name" msgstr "" diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 629dfdaf55e8f4bcda176236b2737c8f19f7f7ef..976fe214c95fe85eb5c16897d90a561a33c9b254 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -2056,6 +2056,68 @@ def invite_to_group(group, inviter:, user: nil, tasks_to_be_done: []) end end + describe 'membership about to expire' do + context "with group membership" do + let_it_be(:group_member) { create(:group_member, source: group, expires_at: 7.days.from_now) } + + subject { described_class.member_about_to_expire_email("Namespace", group_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + it_behaves_like 'appearance header and footer enabled' + it_behaves_like 'appearance header and footer not enabled' + + it 'contains all the useful information' do + is_expected.to deliver_to group_member.user.email + is_expected.to have_subject "Your membership will expire in 7 days" + is_expected.to have_body_text "group will expire in 7 days." + is_expected.to have_body_text group_url(group) + is_expected.to have_body_text group_group_members_url(group) + end + end + + context "with project membership" do + let_it_be(:project_member) { create(:project_member, source: project, expires_at: 7.days.from_now) } + + subject { described_class.member_about_to_expire_email('Project', project_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + it_behaves_like 'appearance header and footer enabled' + it_behaves_like 'appearance header and footer not enabled' + + it 'contains all the useful information' do + is_expected.to deliver_to project_member.user.email + is_expected.to have_subject "Your membership will expire in 7 days" + is_expected.to have_body_text "project will expire in 7 days." + is_expected.to have_body_text project_url(project) + is_expected.to have_body_text project_project_members_url(project) + end + end + + context "with expired membership" do + let_it_be(:project_member) { create(:project_member, source: project, expires_at: Date.today) } + + subject { described_class.member_about_to_expire_email('Project', project_member.id) } + + it 'not deliver expiry email' do + should_not_email_anyone + end + end + + context "with expiry notified membership" do + let_it_be(:project_member) { create(:project_member, source: project, expires_at: 7.days.from_now, expiry_notified_at: Date.today) } + + subject { described_class.member_about_to_expire_email('Project', project_member.id) } + + it 'not deliver expiry email' do + should_not_email_anyone + end + end + end + describe 'admin notification' do let(:example_site_path) { root_path } let(:user) { create(:user) } diff --git a/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb b/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..524354ecc9af5110328255c7bc21c590c0a76746 --- /dev/null +++ b/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe AddIndexForMemberExpiringQuery, :migration, feature_category: :groups_and_projects do + let(:index_name) { 'index_members_on_expiring_at_access_level_id' } + + it 'correctly migrates up and down' do + expect(subject).not_to be_index_exists_by_name(:members, index_name) + + migrate! + + expect(subject).to be_index_exists_by_name(:members, index_name) + end +end diff --git a/spec/migrations/add_expiry_notified_at_to_member_spec.rb b/spec/migrations/add_expiry_notified_at_to_member_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..30eaf06529e795491b92cd500dbb612bac5d41a5 --- /dev/null +++ b/spec/migrations/add_expiry_notified_at_to_member_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe AddExpiryNotifiedAtToMember, feature_category: :system_access do + let(:members) { table(:members) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(members.column_names).not_to include('expiry_notified_at') + } + + migration.after -> { + members.reset_column_information + expect(members.column_names).to include('expiry_notified_at') + } + end + end +end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index d21edea9751d97eb7d24de3da8dd1bc9471dd91f..f8aaae3edada303f0f658ca43cdf1bca063a67cc 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -351,6 +351,19 @@ it { is_expected.to include(expiring_tomorrow, not_expiring) } end + describe '.expiring_and_not_notified' do + let_it_be(:expiring_in_5_days) { create(:group_member, expires_at: 5.days.from_now) } + let_it_be(:expiring_in_5_days_with_notified) { create(:group_member, expires_at: 5.days.from_now, expiry_notified_at: Date.today) } + let_it_be(:expiring_in_7_days) { create(:group_member, expires_at: 7.days.from_now) } + let_it_be(:expiring_in_10_days) { create(:group_member, expires_at: 10.days.from_now) } + let_it_be(:not_expiring) { create(:group_member) } + + subject { described_class.expiring_and_not_notified(7.days.from_now.to_date) } + + it { is_expected.not_to include(expiring_in_5_days_with_notified, expiring_in_10_days, not_expiring) } + it { is_expected.to include(expiring_in_5_days, expiring_in_7_days) } + end + describe '.created_today' do let_it_be(:now) { Time.current } let_it_be(:created_today) { create(:group_member, created_at: now.beginning_of_day) } diff --git a/spec/services/members/update_service_spec.rb b/spec/services/members/update_service_spec.rb index 1c4b1abcfdb67d0232138731b7e6e236d41e9c97..3860543a85e0d7b7cdce24c8df09b906e6d29fd4 100644 --- a/spec/services/members/update_service_spec.rb +++ b/spec/services/members/update_service_spec.rb @@ -219,6 +219,25 @@ end end end + + context 'when project members expiration date is updated with expiry_notified_at' do + let_it_be(:params) { { expires_at: 20.days.from_now } } + + before do + group_project.group.add_owner(current_user) + members.each do |member| + member.update!(expiry_notified_at: Date.today) + end + end + + it "clear expiry_notified_at" do + subject + + members.each do |member| + expect(member.reload.expiry_notified_at).to be_nil + end + end + end end shared_examples 'updating a group' do @@ -250,6 +269,24 @@ subject end end + + context 'when group members expiration date is updated with expiry_notified_at' do + let_it_be(:params) { { expires_at: 20.days.from_now } } + + before do + members.each do |member| + member.update!(expiry_notified_at: Date.today) + end + end + + it "clear expiry_notified_at" do + subject + + members.each do |member| + expect(member.reload.expiry_notified_at).to be_nil + end + end + end end subject { update_service.execute(members, permission: permission) } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 1d1dd045a09ce30a063a6026c7ef70300b637406..d3cc367649fc89a269a1dc631a465ef57b4c001e 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -3380,6 +3380,27 @@ def commit_to_hash(commit) end end + describe '#member_about_to_expire' do + let_it_be(:group_member) { create(:group_member, expires_at: 7.days.from_now.to_date) } + let_it_be(:project_member) { create(:project_member, expires_at: 7.days.from_now.to_date) } + + context "with group member" do + it 'emails the user that their group membership will be expired' do + notification.member_about_to_expire(group_member) + + should_email(group_member.user) + end + end + + context "with project member" do + it 'emails the user that their project membership will be expired' do + notification.member_about_to_expire(project_member) + + should_email(project_member.user) + end + end + end + def create_member! create(:project_member, user: added_user, project: project) end diff --git a/spec/workers/members/expiring_email_notification_worker_spec.rb b/spec/workers/members/expiring_email_notification_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..600a81b37b8e5865af76ad217fc939e75976dcb5 --- /dev/null +++ b/spec/workers/members/expiring_email_notification_worker_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::ExpiringEmailNotificationWorker, type: :worker, feature_category: :system_access do + subject(:worker) { described_class.new } + + let_it_be(:member) { create(:project_member, :guest, expires_at: 7.days.from_now.to_date) } + let_it_be(:notified_member) do + create(:project_member, :guest, expires_at: 7.days.from_now.to_date, expiry_notified_at: Date.today) + end + + describe '#perform' do + context "with not notified member" do + it "notify member" do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).to receive(:member_about_to_expire).with(member) + end + + worker.perform(member.id) + + expect(member.reload.expiry_notified_at).to be_present + end + end + + context "with notified member" do + it "not notify member" do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).not_to receive(:member_about_to_expire).with(notified_member) + end + + worker.perform(notified_member.id) + end + end + + context "when feature member_expiring_email_notification is disabled" do + before do + stub_feature_flags(member_expiring_email_notification: false) + end + + it "not notify member" do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).not_to receive(:member_about_to_expire).with(member) + end + + worker.perform(member.id) + end + end + end +end diff --git a/spec/workers/members/expiring_worker_spec.rb b/spec/workers/members/expiring_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3f46548dbb3adde4ab6397d5271ff2eacc4b65be --- /dev/null +++ b/spec/workers/members/expiring_worker_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::ExpiringWorker, type: :worker, feature_category: :system_access do + subject(:worker) { described_class.new } + + describe '#perform' do + let_it_be(:expiring_7_days_project_member) { create(:project_member, :guest, expires_at: 7.days.from_now) } + let_it_be(:expiring_7_days_group_member) { create(:group_member, :guest, expires_at: 7.days.from_now) } + let_it_be(:expiring_10_days_project_member) { create(:project_member, :guest, expires_at: 10.days.from_now) } + let_it_be(:expiring_5_days_project_member) { create(:project_member, :guest, expires_at: 5.days.from_now) } + let_it_be(:expiring_7_days_blocked_project_member) do + create(:project_member, :guest, :blocked, expires_at: 7.days.from_now) + end + + let(:notifiy_worker) { Members::ExpiringEmailNotificationWorker } + + it "notifies only active users with membership expiring in less than 7 days" do + expect(notifiy_worker).to receive(:perform_async).with(expiring_7_days_project_member.id) + expect(notifiy_worker).to receive(:perform_async).with(expiring_7_days_group_member.id) + expect(notifiy_worker).to receive(:perform_async).with(expiring_5_days_project_member.id) + + worker.perform + end + end +end