diff --git a/app/workers/notifications/create_date_alerts_notifications_job.rb b/app/workers/notifications/create_date_alerts_notifications_job.rb index 15505fcd2e..3869755d34 100644 --- a/app/workers/notifications/create_date_alerts_notifications_job.rb +++ b/app/workers/notifications/create_date_alerts_notifications_job.rb @@ -23,41 +23,17 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # -# See docs/COPYRIGHT.rdoc for more details. +# See COPYRIGHT and LICENSE files for more details. #++ module Notifications - # Creates date alerts for users whose local time is 1:00 am. - class CreateDateAlertsNotificationsJob < Cron::CronJob - # runs every quarter of an hour, so 00:00, 00:15,..., 15:30, 15:45, 16:00, ... - self.cron_expression = '*/15 * * * *' - - def perform + class CreateDateAlertsNotificationsJob < ApplicationJob + def perform(user) return unless EnterpriseToken.allows_to?(:date_alerts) - service = Service.new(times_from_scheduled_to_execution) - service.call - end - - # Returns times from scheduled execution time to current time in 15 minutes - # steps. - # - # As scheduled execution time can be different from current time by more - # than 15 minutes when workers are busy, all times at 15 minutes interval - # between scheduled time and current time need to be considered to match - # with 1:00am in a time zone. - def times_from_scheduled_to_execution - time = scheduled_time - times = [] - begin - times << time - time += 15.minutes - end while time < Time.current - times - end - - def scheduled_time - self.class.delayed_job.run_at.then { |t| t.change(min: t.min / 15 * 15) } + Service + .new(user) + .call end end end diff --git a/app/workers/notifications/create_date_alerts_notifications_job/alertable_work_packages.rb b/app/workers/notifications/create_date_alerts_notifications_job/alertable_work_packages.rb index 9847c7e679..fb3db9ef7f 100644 --- a/app/workers/notifications/create_date_alerts_notifications_job/alertable_work_packages.rb +++ b/app/workers/notifications/create_date_alerts_notifications_job/alertable_work_packages.rb @@ -23,7 +23,7 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # -# See docs/COPYRIGHT.rdoc for more details. +# See COPYRIGHT and LICENSE files for more details. #++ class Notifications::CreateDateAlertsNotificationsJob::AlertableWorkPackages @@ -34,19 +34,21 @@ class Notifications::CreateDateAlertsNotificationsJob::AlertableWorkPackages end def alertable_for_start - find_alertables - .filter_map { |row| row["id"] if row["start_alert"] } - .then { |ids| WorkPackage.where(id: ids) } + alertable_for("start_alert") end def alertable_for_due - find_alertables - .filter_map { |row| row["id"] if row["due_alert"] } - .then { |ids| WorkPackage.where(id: ids) } + alertable_for("due_alert") end private + def alertable_for(alert) + find_alertables + .filter_map { |row| row["id"] if row[alert] } + .then { |ids| WorkPackage.where(id: ids) } + end + def find_alertables @find_alertables ||= ActiveRecord::Base.connection.execute(query).to_a end diff --git a/app/workers/notifications/create_date_alerts_notifications_job/service.rb b/app/workers/notifications/create_date_alerts_notifications_job/service.rb index acb3719025..ae5b360b7c 100644 --- a/app/workers/notifications/create_date_alerts_notifications_job/service.rb +++ b/app/workers/notifications/create_date_alerts_notifications_job/service.rb @@ -26,36 +26,23 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Creates date alerts notifications for users whose local time is 1am for the -# given run_times. class Notifications::CreateDateAlertsNotificationsJob::Service - attr_reader :run_times - - # @param run_times [Array] the times for which the service is run. - # Must be multiple of 15 minutes (xx:00, xx:15, xx:30, or xx:45). - def initialize(run_times) - @run_times = run_times + def initialize(user) + @user = user end def call return unless EnterpriseToken.allows_to?(:date_alerts) - time_zones = time_zones_covering_1am_local_time - return if time_zones.empty? - - # warning: there may be a subtle bug here: if many run_times are given, time - # zones will have different time shifting. This should be ok: as the period - # covered is small this should not have any impact. If the period is more - # than 23h, then the day will change. - Time.use_zone(time_zones.first) do - User.with_time_zone(time_zones).find_each do |user| - send_date_alert_notifications(user) - end + Time.use_zone(user.time_zone) do + send_date_alert_notifications(user) end end private + attr_accessor :user + def send_date_alert_notifications(user) alertables = Notifications::CreateDateAlertsNotificationsJob::AlertableWorkPackages.new(user) create_date_alert_notifications(user, alertables.alertable_for_start, :date_alert_start_date) @@ -86,20 +73,4 @@ class Notifications::CreateDateAlertsNotificationsJob::Service reason: ) end - - def time_zones_covering_1am_local_time - UserPreferences::UpdateContract - .assignable_time_zones - .select { |time_zone| executing_at_1am_for_timezone?(time_zone) } - .map { |time_zone| time_zone.tzinfo.canonical_zone.name } - end - - def executing_at_1am_for_timezone?(time_zone) - run_times.any? { |time| is_1am?(time, time_zone) } - end - - def is_1am?(time, time_zone) - local_time = time.in_time_zone(time_zone) - local_time.strftime('%H:%M') == '01:00' - end end diff --git a/app/workers/notifications/schedule_date_alerts_notifications_job.rb b/app/workers/notifications/schedule_date_alerts_notifications_job.rb new file mode 100644 index 0000000000..dc5406c788 --- /dev/null +++ b/app/workers/notifications/schedule_date_alerts_notifications_job.rb @@ -0,0 +1,63 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +module Notifications + # Creates date alert jobs for users whose local time is 1:00 am. + class ScheduleDateAlertsNotificationsJob < Cron::CronJob + # runs every quarter of an hour, so 00:00, 00:15,..., 15:30, 15:45, 16:00, ... + self.cron_expression = '*/15 * * * *' + + def perform + return unless EnterpriseToken.allows_to?(:date_alerts) + + service = Service.new(times_from_scheduled_to_execution) + service.call + end + + # Returns times from scheduled execution time to current time in 15 minutes + # steps. + # + # As scheduled execution time can be different from current time by more + # than 15 minutes when workers are busy, all times at 15 minutes interval + # between scheduled time and current time need to be considered to match + # with 1:00am in a time zone. + def times_from_scheduled_to_execution + time = scheduled_time + times = [] + begin + times << time + time += 15.minutes + end while time < Time.current + times + end + + def scheduled_time + self.class.delayed_job.run_at.then { |t| t.change(min: t.min / 15 * 15) } + end + end +end diff --git a/app/workers/notifications/schedule_date_alerts_notifications_job/service.rb b/app/workers/notifications/schedule_date_alerts_notifications_job/service.rb new file mode 100644 index 0000000000..f39c0bbb60 --- /dev/null +++ b/app/workers/notifications/schedule_date_alerts_notifications_job/service.rb @@ -0,0 +1,65 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Creates date alerts notifications for users whose local time is 1am for the +# given run_times. +class Notifications::ScheduleDateAlertsNotificationsJob::Service + attr_reader :run_times + + # @param run_times [Array] the times for which the service is run. + # Must be multiple of 15 minutes (xx:00, xx:15, xx:30, or xx:45). + def initialize(run_times) + @run_times = run_times + end + + def call + return unless EnterpriseToken.allows_to?(:date_alerts) + + User.with_time_zone(time_zones_covering_1am_local_time).find_each do |user| + Notifications::CreateDateAlertsNotificationsJob.perform_later(user) + end + end + + private + + def time_zones_covering_1am_local_time + UserPreferences::UpdateContract + .assignable_time_zones + .select { |time_zone| executing_at_1am_for_timezone?(time_zone) } + .map { |time_zone| time_zone.tzinfo.canonical_zone.name } + end + + def executing_at_1am_for_timezone?(time_zone) + run_times.any? { |time| is_1am?(time, time_zone) } + end + + def is_1am?(time, time_zone) + local_time = time.in_time_zone(time_zone) + local_time.strftime('%H:%M') == '01:00' + end +end diff --git a/config/initializers/cronjobs.rb b/config/initializers/cronjobs.rb index 12ab76221c..086811b26f 100644 --- a/config/initializers/cronjobs.rb +++ b/config/initializers/cronjobs.rb @@ -2,14 +2,14 @@ OpenProject::Application.configure do |application| application.config.to_prepare do - ::Cron::CronJob.register! ::Cron::ClearOldSessionsJob, - ::Cron::ClearTmpCacheJob, - ::Cron::ClearUploadedFilesJob, - ::OAuth::CleanupJob, - ::PaperTrailAudits::CleanupJob, - ::Attachments::CleanupUncontaineredJob, - ::Notifications::CreateDateAlertsNotificationsJob, - ::Notifications::ScheduleReminderMailsJob, - ::Ldap::SynchronizationJob + Cron::CronJob.register! Cron::ClearOldSessionsJob, + Cron::ClearTmpCacheJob, + Cron::ClearUploadedFilesJob, + OAuth::CleanupJob, + PaperTrailAudits::CleanupJob, + Attachments::CleanupUncontaineredJob, + Notifications::ScheduleDateAlertsNotificationsJob, + Notifications::ScheduleReminderMailsJob, + Ldap::SynchronizationJob end end diff --git a/db/migrate/20230105073117_remove_renamed_date_alert_job.rb b/db/migrate/20230105073117_remove_renamed_date_alert_job.rb new file mode 100644 index 0000000000..ad480960b9 --- /dev/null +++ b/db/migrate/20230105073117_remove_renamed_date_alert_job.rb @@ -0,0 +1,37 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class RemoveRenamedDateAlertJob < ActiveRecord::Migration[6.0] + def up + # The job has been renamed to Notifications::ScheduleDateAlertsNotificationsJob. + # The new job will be added on restarting the application. + Delayed::Job + .where('handler LIKE ?', "%job_class: Notifications::CreateDateAlertsNotificationsJob%") + .delete_all + end +end diff --git a/spec/features/notifications/notification_center/notification_center_date_alerts_spec.rb b/spec/features/notifications/notification_center/notification_center_date_alerts_spec.rb index eabf31810f..cda9c5eddd 100644 --- a/spec/features/notifications/notification_center/notification_center_date_alerts_spec.rb +++ b/spec/features/notifications/notification_center/notification_center_date_alerts_spec.rb @@ -166,13 +166,15 @@ describe "Notification center date alerts", js: true, with_settings: { journal_a end def run_create_date_alerts_notifications_job - create_date_alerts_service = Notifications::CreateDateAlertsNotificationsJob::Service.new([timezone_time('1:00', time_zone)]) + create_date_alerts_service = Notifications::ScheduleDateAlertsNotificationsJob::Service + .new([timezone_time('1:00', time_zone)]) travel_to(timezone_time('1:04', time_zone)) create_date_alerts_service.call end before do run_create_date_alerts_notifications_job + perform_enqueued_jobs login_as user visit notifications_center_path end diff --git a/spec/workers/notifications/create_date_alerts_notifications_job_spec.rb b/spec/workers/notifications/create_date_alerts_notifications_job_spec.rb index 9353a80e39..fedb9339c3 100644 --- a/spec/workers/notifications/create_date_alerts_notifications_job_spec.rb +++ b/spec/workers/notifications/create_date_alerts_notifications_job_spec.rb @@ -23,7 +23,7 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # -# See docs/COPYRIGHT.rdoc for more details. +# See COPYRIGHT and LICENSE files for more details. #++ require 'spec_helper' @@ -48,43 +48,19 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % shared_let(:in_3_days) { today + 3.days } shared_let(:in_7_days) { today + 7.days } - shared_let(:user_paris) do + shared_let(:user) do create( :user, firstname: 'Paris', preferences: { time_zone: timezone_paris.name } ) end - shared_let(:user_berlin) do - create( - :user, - firstname: 'Berlin', - preferences: { time_zone: timezone_berlin.name } - ) - end - shared_let(:user_kathmandu) do - create( - :user, - firstname: 'Kathmandu', - preferences: { time_zone: timezone_kathmandu.name } - ) - end shared_let(:alertable_work_packages) do - create_list(:work_package, 2, project:, author: user_paris) - end - - let(:scheduled_job) do - described_class.ensure_scheduled! - described_class.delayed_job + create_list(:work_package, 2, project:, author: user) end - before do - # We need to access the job as stored in the database to get at the run_at time persisted there - allow(ActiveJob::Base) - .to receive(:queue_adapter) - .and_return(ActiveJob::QueueAdapters::DelayedJobAdapter.new) - end + let(:job) { described_class } define :have_a_start_date_alert_notification_for do |work_package| match do |user| @@ -113,9 +89,9 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % when WorkPackage then %i[id start_date due_date assigned_to_id responsible_id] end formatted_pairs = object - .slice(*keys) - .map { |k, v| "#{k}: #{v.is_a?(Date) ? v.to_s : v.inspect}" } - .join(', ') + .slice(*keys) + .map { |k, v| "#{k}: #{v.is_a?(Date) ? v.to_s : v.inspect}" } + .join(', ') "#<#{formatted_pairs}>" end end @@ -147,19 +123,15 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % when WorkPackage then %i[id start_date due_date assigned_to_id responsible_id] end formatted_pairs = object - .slice(*keys) - .map { |k, v| "#{k}: #{v.is_a?(Date) ? v.to_s : v.inspect}" } - .join(', ') + .slice(*keys) + .map { |k, v| "#{k}: #{v.is_a?(Date) ? v.to_s : v.inspect}" } + .join(', ') "#<#{formatted_pairs}>" end end - def set_scheduled_time(run_at) - scheduled_job.update_column(:run_at, run_at) - end - def alertable_work_package(attributes = {}) - assignee = attributes.slice(:responsible, :responsible_id).any? ? nil : user_paris + assignee = attributes.slice(:responsible, :responsible_id).any? ? nil : user attributes = attributes.reverse_merge( start_date: in_1_day, assigned_to: assignee @@ -179,9 +151,8 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % end def run_job(scheduled_at: '1:00', local_time: '1:04', timezone: timezone_paris) - set_scheduled_time(timezone_time(scheduled_at, timezone)) travel_to(timezone_time(local_time, timezone)) do - scheduled_job.reload.invoke_job + job.perform_now(user) yield end @@ -193,57 +164,63 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % closed_work_package = alertable_work_package(status: status_closed) run_job do - expect(user_paris).to have_a_start_date_alert_notification_for(open_work_package) - expect(user_paris).not_to have_a_start_date_alert_notification_for(closed_work_package) + expect(user).to have_a_start_date_alert_notification_for(open_work_package) + expect(user).not_to have_a_start_date_alert_notification_for(closed_work_package) end end - it 'creates date alert notifications only for users whose local time is 1:00 am when the job is executed' do - work_package_for_paris_user = alertable_work_package(assigned_to: user_paris, - start_date: timezone_paris.today + 1.day) - work_package_for_kathmandu_user = alertable_work_package(assigned_to: user_kathmandu, - start_date: timezone_kathmandu.today + 1.day) + it 'creates date alert notifications if user is assigned to the work package' do + work_package_assigned = alertable_work_package(assigned_to: user) - run_job(timezone: timezone_paris) do - expect(user_paris).to have_a_start_date_alert_notification_for(work_package_for_paris_user) - expect(user_kathmandu).not_to have_a_start_date_alert_notification_for(work_package_for_kathmandu_user) + run_job do + expect(user).to have_a_start_date_alert_notification_for(work_package_assigned) end + end - # change timezone to cover Kathmandu - run_job(timezone: timezone_kathmandu) do - expect(user_kathmandu).to have_a_start_date_alert_notification_for(work_package_for_kathmandu_user) + it 'creates date alert notifications if user is accountable of the work package' do + work_package_accountable = alertable_work_package(responsible: user) + + run_job do + expect(user).to have_a_start_date_alert_notification_for(work_package_accountable) end end - it 'creates date alert notifications if user is assigned to or accountable of the work package' do - work_package_assigned = alertable_work_package(assigned_to: user_paris) - work_package_accountable = alertable_work_package(responsible: user_paris) + it 'creates date alert notifications if user is watcher of the work package' do + work_package_watched = alertable_work_package(responsible: nil) + build(:watcher, watchable: work_package_watched, user:).save(validate: false) run_job do - expect(user_paris).to have_a_start_date_alert_notification_for(work_package_assigned) - expect(user_paris).to have_a_start_date_alert_notification_for(work_package_accountable) + expect(user).to have_a_start_date_alert_notification_for(work_package_watched) end end - it 'creates start and finish date alert notifications based on user notification settings' do - user_paris.notification_settings.first.update( + it 'creates start date alert notifications based on user notification settings' do + user.notification_settings.first.update( start_date: 1, due_date: nil ) - user_berlin.notification_settings.first.update( + work_package = alertable_work_package(assigned_to: user, + start_date: in_1_day, + due_date: in_3_days) + + run_job do + expect(user).to have_a_start_date_alert_notification_for(work_package) + expect(user).not_to have_a_due_date_alert_notification_for(work_package) + end + end + + it 'creates due date alert notifications based on user notification settings' do + user.notification_settings.first.update( start_date: nil, due_date: 3 ) - work_package = alertable_work_package(assigned_to: user_paris, - responsible: user_berlin, + work_package = alertable_work_package(assigned_to: user, start_date: in_1_day, due_date: in_3_days) run_job do - expect(user_paris).to have_a_start_date_alert_notification_for(work_package) - expect(user_paris).not_to have_a_due_date_alert_notification_for(work_package) - expect(user_berlin).not_to have_a_start_date_alert_notification_for(work_package) - expect(user_berlin).to have_a_due_date_alert_notification_for(work_package) + expect(user).not_to have_a_start_date_alert_notification_for(work_package) + expect(user).to have_a_due_date_alert_notification_for(work_package) end end @@ -252,7 +229,7 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % work_package = alertable_work_package run_job do - expect(user_paris).not_to have_a_start_date_alert_notification_for(work_package) + expect(user).not_to have_a_start_date_alert_notification_for(work_package) end end end @@ -260,52 +237,52 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % context 'when project notification settings are defined for a user' do it 'creates date alert notifications using these settings for work packages of the project' do # global notification settings - user_paris.notification_settings.first.update( + user.notification_settings.first.update( start_date: 1, due_date: nil ) # project notifications settings - user_paris.notification_settings.create( + user.notification_settings.create( project:, start_date: nil, due_date: 7 ) - silent_work_package = alertable_work_package(assigned_to: user_paris, + silent_work_package = alertable_work_package(assigned_to: user, project:, start_date: in_1_day, due_date: in_1_day) - noisy_work_package = alertable_work_package(assigned_to: user_paris, + noisy_work_package = alertable_work_package(assigned_to: user, project:, start_date: in_7_days, due_date: in_7_days) run_job do - expect(user_paris).not_to have_a_start_date_alert_notification_for(silent_work_package) - expect(user_paris).not_to have_a_due_date_alert_notification_for(silent_work_package) - expect(user_paris).not_to have_a_start_date_alert_notification_for(noisy_work_package) - expect(user_paris).to have_a_due_date_alert_notification_for(noisy_work_package) + expect(user).not_to have_a_start_date_alert_notification_for(silent_work_package) + expect(user).not_to have_a_due_date_alert_notification_for(silent_work_package) + expect(user).not_to have_a_start_date_alert_notification_for(noisy_work_package) + expect(user).to have_a_due_date_alert_notification_for(noisy_work_package) end end end context 'with existing date alerts' do it 'marks them as read when new ones are created' do - work_package = alertable_work_package(assigned_to: user_paris, + work_package = alertable_work_package(assigned_to: user, start_date: in_1_day, due_date: in_1_day) existing_start_notification = create(:notification, resource: work_package, - recipient: user_paris, + recipient: user, reason: :date_alert_start_date) existing_due_notification = create(:notification, resource: work_package, - recipient: user_paris, + recipient: user, reason: :date_alert_due_date) run_job do expect(existing_start_notification.reload).to have_attributes(read_ian: true) expect(existing_due_notification.reload).to have_attributes(read_ian: true) - unread_date_alert_notifications = Notification.where(recipient: user_paris, + unread_date_alert_notifications = Notification.where(recipient: user, read_ian: false, resource: work_package) expect(unread_date_alert_notifications.pluck(:reason)) @@ -315,27 +292,27 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % # rubocop:disable RSpec/ExampleLength it 'does not mark them as read when if no new notifications are created' do - work_package_start = alertable_work_package(assigned_to: user_paris, + work_package_start = alertable_work_package(assigned_to: user, start_date: in_1_day, due_date: nil) - work_package_due = alertable_work_package(assigned_to: user_paris, + work_package_due = alertable_work_package(assigned_to: user, start_date: nil, due_date: in_1_day) existing_wp_start_start_notif = create(:notification, reason: :date_alert_start_date, - recipient: user_paris, + recipient: user, resource: work_package_start) existing_wp_start_due_notif = create(:notification, reason: :date_alert_due_date, - recipient: user_paris, + recipient: user, resource: work_package_start) existing_wp_due_start_notif = create(:notification, reason: :date_alert_start_date, - recipient: user_paris, + recipient: user, resource: work_package_due) existing_wp_due_due_notif = create(:notification, reason: :date_alert_due_date, - recipient: user_paris, + recipient: user, resource: work_package_due) run_job do @@ -347,45 +324,5 @@ describe Notifications::CreateDateAlertsNotificationsJob, type: :job, with_ee: % end # rubocop:enable RSpec/ExampleLength end - - context 'when scheduled and executed at 01:00 am local time' do - it 'creates a start date alert notification for a user in the same time zone' do - work_package = alertable_work_package - - run_job(scheduled_at: '1:00', local_time: '1:00') do - expect(user_paris).to have_a_start_date_alert_notification_for(work_package) - end - end - end - - context 'when scheduled and executed at 01:14 am local time' do - it 'creates a start date alert notification for a user in the same time zone' do - work_package = alertable_work_package - - run_job(scheduled_at: '1:14', local_time: '1:14') do - expect(user_paris).to have_a_start_date_alert_notification_for(work_package) - end - end - end - - context 'when scheduled and executed at 01:15 am local time' do - it 'does not create a start date alert notification for a user in the same time zone' do - work_package = alertable_work_package - - run_job(scheduled_at: '1:15', local_time: '1:15') do - expect(user_paris).not_to have_a_start_date_alert_notification_for(work_package) - end - end - end - - context 'when scheduled at 01:00 am local time and executed at 01:37 am local time' do - it 'creates a start date alert notification for a user in the same time zone' do - work_package = alertable_work_package - - run_job(scheduled_at: '1:00', local_time: '1:37') do - expect(user_paris).to have_a_start_date_alert_notification_for(work_package) - end - end - end end end diff --git a/spec/workers/notifications/schedule_date_alerts_notifications_job_spec.rb b/spec/workers/notifications/schedule_date_alerts_notifications_job_spec.rb new file mode 100644 index 0000000000..6de7d0de82 --- /dev/null +++ b/spec/workers/notifications/schedule_date_alerts_notifications_job_spec.rb @@ -0,0 +1,198 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe Notifications::ScheduleDateAlertsNotificationsJob, type: :job, with_ee: %i[date_alerts] do + include ActiveSupport::Testing::TimeHelpers + + shared_let(:project) { create(:project, name: 'main') } + + # Paris and Berlin are both UTC+01:00 (CET) or UTC+02:00 (CEST) + shared_let(:timezone_paris) { ActiveSupport::TimeZone['Europe/Paris'] } + # Kathmandu is UTC+05:45 (no DST) + shared_let(:timezone_kathmandu) { ActiveSupport::TimeZone['Asia/Kathmandu'] } + + shared_let(:user_paris) do + create( + :user, + firstname: 'Paris', + preferences: { time_zone: timezone_paris.name } + ) + end + shared_let(:user_kathmandu) do + create( + :user, + firstname: 'Kathmandu', + preferences: { time_zone: timezone_kathmandu.name } + ) + end + + let(:schedule_job) do + described_class.ensure_scheduled! + described_class.delayed_job + end + + before do + # We need to access the job as stored in the database to get at the run_at time persisted there + allow(ActiveJob::Base) + .to receive(:queue_adapter) + .and_return(ActiveJob::QueueAdapters::DelayedJobAdapter.new) + schedule_job + end + + def set_scheduled_time(run_at) + schedule_job.update_column(:run_at, run_at) + end + + # Converts "hh:mm" into { hour: h, min: m } + def time_hash(time) + %i[hour min].zip(time.split(':', 2).map(&:to_i)).to_h + end + + def timezone_time(time, timezone) + timezone.now.change(time_hash(time)) + end + + def run_job(scheduled_at: '1:00', local_time: '1:04', timezone: timezone_paris) + set_scheduled_time(timezone_time(scheduled_at, timezone)) + travel_to(timezone_time(local_time, timezone)) do + schedule_job.reload.invoke_job + + yield if block_given? + end + end + + def deserialized_of_job(job) + deserializer_class = Class.new do + include(ActiveJob::Arguments) + end + + deserializer = deserializer_class.new + + deserializer.deserialize(job.payload_object.job_data).to_h + end + + def expect_job(job, klass, *arguments) + job_data = deserialized_of_job(job) + expect(job_data['job_class']) + .to eql klass + expect(job_data['arguments']) + .to match_array arguments + end + + shared_examples_for 'job execution creates date alerts creation job' do + let(:timezone) { timezone_paris } + let(:scheduled_at) { '1:00' } + let(:local_time) { '1:04' } + let(:user) { user_paris } + + it 'creates the job for the user' do + expect do + run_job(timezone:, scheduled_at:, local_time:) do + expect_job(Delayed::Job.last, "Notifications::CreateDateAlertsNotificationsJob", user) + end + end.to change(Delayed::Job, :count).by 1 + end + end + + shared_examples_for 'job execution creates no date alerts creation job' do + let(:timezone) { timezone_paris } + let(:scheduled_at) { '1:00' } + let(:local_time) { '1:04' } + + it 'creates no job' do + expect do + run_job(timezone:, scheduled_at:, local_time:) + end.not_to change(Delayed::Job, :count) + end + end + + describe '#perform' do + context 'for users whose local time is 1:00 am (UTC+1) when the job is executed' do + it_behaves_like 'job execution creates date alerts creation job' do + let(:timezone) { timezone_paris } + let(:scheduled_at) { '1:00' } + let(:local_time) { '1:04' } + let(:user) { user_paris } + end + end + + context 'for users whose local time is 1:00 am (UTC+05:45) when the job is executed' do + it_behaves_like 'job execution creates date alerts creation job' do + let(:timezone) { timezone_kathmandu } + let(:scheduled_at) { '1:00' } + let(:local_time) { '1:04' } + let(:user) { user_kathmandu } + end + end + + context 'without enterprise token', with_ee: false do + it_behaves_like 'job execution creates no date alerts creation job' do + let(:timezone) { timezone_paris } + let(:scheduled_at) { '1:00' } + let(:local_time) { '1:04' } + end + end + + context 'when scheduled and executed at 01:00 am local time' do + it_behaves_like 'job execution creates date alerts creation job' do + let(:timezone) { timezone_paris } + let(:scheduled_at) { '1:00' } + let(:local_time) { '1:00' } + let(:user) { user_paris } + end + end + + context 'when scheduled and executed at 01:14 am local time' do + it_behaves_like 'job execution creates date alerts creation job' do + let(:timezone) { timezone_paris } + let(:scheduled_at) { '1:14' } + let(:local_time) { '1:14' } + let(:user) { user_paris } + end + end + + context 'when scheduled and executed at 01:15 am local time' do + it_behaves_like 'job execution creates no date alerts creation job' do + let(:timezone) { timezone_paris } + let(:scheduled_at) { '1:15' } + let(:local_time) { '1:15' } + end + end + + context 'when scheduled at 01:00 am local time and executed at 01:37 am local time' do + it_behaves_like 'job execution creates date alerts creation job' do + let(:timezone) { timezone_paris } + let(:scheduled_at) { '1:00' } + let(:local_time) { '1:37' } + let(:user) { user_paris } + end + end + end +end