Merge pull request #9611 from opf/implementation/38707-reminder-emails-scheduling
Schedule reminder mails on a CRON like delayed jobpull/9699/head
commit
87ed8ab4d2
@ -0,0 +1,132 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2021 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. |
||||
#++ |
||||
|
||||
module Users::Scopes |
||||
module HavingReminderMailToSend |
||||
extend ActiveSupport::Concern |
||||
|
||||
class_methods do |
||||
# Returns all users for which a reminder mails should be sent now. A user will be included if: |
||||
# * That user has an unread notification |
||||
# * The user hasn't been informed about the unread notification before |
||||
# * The user has configured reminder mails to be within the time frame between the provided time and now. |
||||
# This assumes that users only have full hours specified for the times they desire |
||||
# to receive a reminder mail at. |
||||
# @param [DateTime] earliest_time The earliest time to consider as a matching slot. All quarter hours from that time |
||||
# to now are included. |
||||
# Only the time part is used which is moved forward to the next quarter hour (e.g. 2021-05-03 10:34:12+02:00 -> 08:45:00). |
||||
# This is done because time zones always have a mod(15) == 0 minutes offset. |
||||
# Needs to be before the current time. |
||||
def having_reminder_mail_to_send(earliest_time) |
||||
local_times = local_times_from(earliest_time) |
||||
|
||||
return none if local_times.empty? |
||||
|
||||
# Left outer join as not all user instances have preferences associated |
||||
# but we still want to select them. |
||||
recipient_candidates = User |
||||
.active |
||||
.left_joins(:preference) |
||||
.joins(local_time_join(local_times)) |
||||
|
||||
subscriber_ids = Notification |
||||
.unsent_reminders_before(recipient: recipient_candidates, time: Time.current) |
||||
.group(:recipient_id) |
||||
.select(:recipient_id) |
||||
|
||||
where(id: subscriber_ids) |
||||
end |
||||
|
||||
def local_time_join(local_times) |
||||
# Joins the times local to the user preferences and then checks whether: |
||||
# * reminders are enabled |
||||
# * any of the configured reminder time is the local time |
||||
# If no time zone is present, utc is assumed. |
||||
# If no reminder settings are present, sending a reminder at 08:00 local time is assumed. |
||||
<<~SQL.squish |
||||
JOIN ( |
||||
SELECT * FROM #{arel_table.grouping(Arel::Nodes::ValuesList.new(local_times)).as('t(time, zone)').to_sql} |
||||
) AS local_times |
||||
ON COALESCE(user_preferences.settings->>'time_zone', 'UTC') = local_times.zone |
||||
AND ( |
||||
( |
||||
user_preferences.settings->'daily_reminders'->'times' IS NULL |
||||
AND local_times.time = '08:00:00+00:00' |
||||
) |
||||
OR |
||||
( |
||||
(user_preferences.settings->'daily_reminders'->'enabled')::boolean |
||||
AND user_preferences.settings->'daily_reminders'->'times' ? local_times.time |
||||
) |
||||
) |
||||
SQL |
||||
end |
||||
|
||||
def local_times_from(earliest_time) |
||||
times = quarters_between_earliest_and_now(earliest_time) |
||||
|
||||
times_for_zones(times) |
||||
end |
||||
|
||||
def times_for_zones(times) |
||||
ActiveSupport::TimeZone |
||||
.all |
||||
.map do |z| |
||||
times.map do |time| |
||||
local_time = time.in_time_zone(z) |
||||
|
||||
# Since only full hours can be configured, we can disregard any local time that is not |
||||
# a full hour. |
||||
next if local_time.min != 0 |
||||
|
||||
[local_time.strftime('%H:00:00+00:00'), z.name.gsub("'", "''")] |
||||
end |
||||
end |
||||
.flatten(1) |
||||
.compact |
||||
end |
||||
|
||||
def quarters_between_earliest_and_now(earliest_time) |
||||
latest_time = Time.current |
||||
raise ArgumentError if latest_time < earliest_time || (latest_time - earliest_time) > 1.day |
||||
|
||||
quarters = ((latest_time - earliest_time) / 60 / 15).floor |
||||
|
||||
(1..quarters).each_with_object([next_quarter_hour(earliest_time)]) do |i, times| |
||||
times << (times.last + (i * 15.minutes)) |
||||
end |
||||
end |
||||
|
||||
def next_quarter_hour(time) |
||||
(time + (time.min % 15 == 0 ? 0.minutes : (15 - (time.min % 15)).minutes)) |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,46 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2021 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 |
||||
class ScheduleReminderMailsJob < Cron::CronJob |
||||
# runs every quarter of an hour, so 00:00, 00:15... |
||||
self.cron_expression = '*/15 * * * *' |
||||
|
||||
def perform |
||||
User.having_reminder_mail_to_send(run_at).pluck(:id).each do |user_id| |
||||
Mails::ReminderJob.perform_later(user_id) |
||||
end |
||||
end |
||||
|
||||
def run_at |
||||
self.class.delayed_job.run_at |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,18 @@ |
||||
class AddUserPreferenceSettingsIndices < ActiveRecord::Migration[6.1] |
||||
def change |
||||
add_index :user_preferences, |
||||
"(settings->'daily_reminders'->'enabled')", |
||||
using: :gin, |
||||
name: 'index_user_prefs_settings_daily_reminders_enabled' |
||||
|
||||
add_index :user_preferences, |
||||
"(settings->'daily_reminders'->'times')", |
||||
using: :gin, |
||||
name: 'index_user_prefs_settings_daily_reminders_times' |
||||
|
||||
add_index :user_preferences, |
||||
"(settings->'time_zone')", |
||||
using: :gin, |
||||
name: 'index_user_prefs_settings_time_zone' |
||||
end |
||||
end |
@ -1,77 +0,0 @@ |
||||
require 'spec_helper' |
||||
require 'support/pages/my/notifications' |
||||
|
||||
# TODO: This feature spec is to be replaced by the reminder_mail_spec.rb in the same directory. |
||||
describe "Digest email", type: :feature, js: true do |
||||
let!(:project) { FactoryBot.create :project, members: { current_user => role } } |
||||
let!(:mute_project) { FactoryBot.create :project, members: { current_user => role } } |
||||
let(:notification_settings_page) { Pages::My::Notifications.new(current_user) } |
||||
let(:role) { FactoryBot.create(:role, permissions: %i[view_work_packages]) } |
||||
let(:other_user) { FactoryBot.create(:user) } |
||||
let(:work_package) { FactoryBot.create(:work_package, project: project) } |
||||
let(:watched_work_package) { FactoryBot.create(:work_package, project: project, watcher_users: [current_user]) } |
||||
let(:involved_work_package) { FactoryBot.create(:work_package, project: project, assigned_to: current_user) } |
||||
|
||||
current_user do |
||||
FactoryBot.create :user, |
||||
notification_settings: [ |
||||
FactoryBot.build(:mail_digest_notification_setting, |
||||
involved: true, |
||||
watched: true, |
||||
mentioned: true, |
||||
work_package_commented: true, |
||||
work_package_created: true, |
||||
work_package_processed: true, |
||||
work_package_prioritized: true, |
||||
work_package_scheduled: true, |
||||
all: true) |
||||
] |
||||
end |
||||
|
||||
before do |
||||
watched_work_package |
||||
work_package |
||||
involved_work_package |
||||
|
||||
allow(CustomStyle.current) |
||||
.to receive(:logo).and_return(nil) |
||||
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear |
||||
end |
||||
|
||||
it 'sends a digest mail based on the configuration', with_settings: { journal_aggregation_time_minutes: 0 } do |
||||
# Perform some actions the user listens to |
||||
User.execute_as other_user do |
||||
note = <<~NOTE |
||||
Hey <mention class=\"mention\" |
||||
data-id=\"#{current_user.id}\" |
||||
data-type=\"user\" |
||||
data-text=\"@#{current_user.name}\"> |
||||
@#{current_user.name} |
||||
</mention> |
||||
NOTE |
||||
|
||||
work_package.add_journal(other_user, note) |
||||
work_package.save! |
||||
|
||||
watched_work_package.subject = 'New watched work package subject' |
||||
watched_work_package.save! |
||||
|
||||
involved_work_package.description = 'New involved work package description' |
||||
involved_work_package.save! |
||||
end |
||||
|
||||
# Have to explicitly execute the delayed jobs. If we were to execute all |
||||
# by wrapping the above, work package altering code, inside a |
||||
# perform_enqueued_jobs block, the digest job would be executed right away |
||||
# so that the second update would trigger a new digest. But we want to test |
||||
# that only one digest is sent out |
||||
5.times { perform_enqueued_jobs } |
||||
|
||||
expect(ActionMailer::Base.deliveries.length) |
||||
.to be 1 |
||||
|
||||
expect(ActionMailer::Base.deliveries.first.subject) |
||||
.to eql "OpenProject - 1 unread notification including a mention" |
||||
end |
||||
end |
@ -0,0 +1,465 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2021 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 User, '.having_reminder_mail_to_send', type: :model do |
||||
subject(:scope) do |
||||
described_class.having_reminder_mail_to_send(scope_time) |
||||
end |
||||
|
||||
# As it is hard to mock PostgreSQL's "now()" method, in the specs here we need to adopt the slot time |
||||
# relative to the local time of the user that we want to hit. |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['UTC'].parse("08:10:59") } |
||||
let(:scope_time) { ActiveSupport::TimeZone['UTC'].parse("08:00") } |
||||
|
||||
let(:paris_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Paris', |
||||
preferences: { |
||||
time_zone: "Paris", |
||||
daily_reminders: paris_user_daily_reminders |
||||
} |
||||
) |
||||
end |
||||
let(:paris_user_daily_reminders) do |
||||
{ |
||||
enabled: true, |
||||
times: [hitting_reminder_slot_for("Paris", current_utc_time)] |
||||
} |
||||
end |
||||
let(:notifications) { FactoryBot.create(:notification, recipient: paris_user, created_at: 5.minutes.ago) } |
||||
let(:users) { [paris_user] } |
||||
|
||||
before do |
||||
allow(Time).to receive(:current).and_return(current_utc_time) |
||||
notifications |
||||
users |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time' do |
||||
it 'contains the user' do |
||||
expect(scope) |
||||
.to match_array([paris_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is not matching the configured time' do |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['UTC'].parse("08:20:59") } |
||||
let(:scope_time) { ActiveSupport::TimeZone['UTC'].parse("08:15") } |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time (in a non CET time zone)' do |
||||
let(:moscow_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Moscow', |
||||
preferences: { |
||||
time_zone: "Moscow", |
||||
daily_reminders: { |
||||
enabled: true, |
||||
times: [hitting_reminder_slot_for("Moscow", current_utc_time)] |
||||
} |
||||
} |
||||
) |
||||
end |
||||
let(:notifications) do |
||||
FactoryBot.create(:notification, recipient: moscow_user, created_at: 5.minutes.ago) |
||||
end |
||||
let(:users) { [moscow_user] } |
||||
|
||||
it 'contains the user' do |
||||
expect(scope) |
||||
.to match_array([moscow_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching one of the configured times' do |
||||
let(:paris_user_daily_reminders) do |
||||
{ |
||||
enabled: true, |
||||
times: [ |
||||
hitting_reminder_slot_for("Paris", current_utc_time - 3.hours), |
||||
hitting_reminder_slot_for("Paris", current_utc_time), |
||||
hitting_reminder_slot_for("Paris", current_utc_time + 3.hours) |
||||
] |
||||
} |
||||
end |
||||
|
||||
it 'contains the user' do |
||||
expect(scope) |
||||
.to match_array([paris_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user who has configured a slot between the earliest_time (in local time) and his current local time' do |
||||
let(:paris_user_daily_reminders) do |
||||
{ |
||||
enabled: true, |
||||
times: [ |
||||
hitting_reminder_slot_for("Paris", current_utc_time - 2.hours), |
||||
hitting_reminder_slot_for("Paris", current_utc_time + 3.hours) |
||||
] |
||||
} |
||||
end |
||||
let(:scope_time) { ActiveSupport::TimeZone['UTC'].parse("06:00") } |
||||
|
||||
it 'contains the user' do |
||||
expect(scope) |
||||
.to match_array([paris_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user who has configured a slot before the earliest_time (in local time) and after his current local time' do |
||||
let(:paris_user_daily_reminders) do |
||||
{ |
||||
enabled: true, |
||||
times: [ |
||||
hitting_reminder_slot_for("Paris", current_utc_time - 3.hours), |
||||
hitting_reminder_slot_for("Paris", current_utc_time + 1.hour) |
||||
] |
||||
} |
||||
end |
||||
let(:scope_time) { current_utc_time - 2.hours } |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time but without a notification' do |
||||
let(:notifications) do |
||||
nil |
||||
end |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time but with the reminder being deactivated' do |
||||
let(:paris_user_daily_reminders) do |
||||
{ |
||||
enabled: false, |
||||
times: [hitting_reminder_slot_for("Paris", current_utc_time)] |
||||
} |
||||
end |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time but without a daily_reminders setting at 8:00' do |
||||
let(:paris_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Paris', |
||||
preferences: { |
||||
time_zone: "Paris" |
||||
} |
||||
) |
||||
end |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['Paris'].parse("08:09").utc } |
||||
let(:scope_time) { ActiveSupport::TimeZone['Paris'].parse("08:00") } |
||||
|
||||
it 'contains the user' do |
||||
expect(scope) |
||||
.to match_array([paris_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time but without a daily_reminders setting at 10:00' do |
||||
let(:paris_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Paris', |
||||
preferences: { |
||||
time_zone: "Paris" |
||||
} |
||||
) |
||||
end |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['Paris'].parse("10:00").utc } |
||||
let(:scope_time) { ActiveSupport::TimeZone['Paris'].parse("10:00") } |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user who is in a 45 min time zone and having reminder set to 8:00 and being executed at 8:10' do |
||||
let(:kathmandu_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Kathmandu', |
||||
preferences: { |
||||
time_zone: "Kathmandu", |
||||
daily_reminders: { |
||||
enabled: true, |
||||
times: [hitting_reminder_slot_for("Asia/Kathmandu", current_utc_time)] |
||||
} |
||||
} |
||||
) |
||||
end |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['Asia/Kathmandu'].parse("08:10").utc } |
||||
let(:scope_time) { ActiveSupport::TimeZone['Asia/Kathmandu'].parse("08:00").utc } |
||||
let(:notifications) do |
||||
FactoryBot.create(:notification, recipient: kathmandu_user, created_at: 5.minutes.ago) |
||||
end |
||||
|
||||
let(:users) { [kathmandu_user] } |
||||
|
||||
it 'contains the user' do |
||||
expect(scope) |
||||
.to match_array([kathmandu_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user who is in a 45 min time zone and having reminder set to 8:00 and being executed at 8:40' do |
||||
let(:kathmandu_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Kathmandu', |
||||
preferences: { |
||||
time_zone: "Kathmandu", |
||||
daily_reminders: { |
||||
enabled: true, |
||||
times: [hitting_reminder_slot_for("Asia/Kathmandu", current_utc_time)] |
||||
} |
||||
} |
||||
) |
||||
end |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['Asia/Kathmandu'].parse("08:40").utc } |
||||
let(:scope_time) { ActiveSupport::TimeZone['Asia/Kathmandu'].parse("08:30").utc } |
||||
let(:notifications) do |
||||
FactoryBot.create(:notification, recipient: kathmandu_user, created_at: 5.minutes.ago) |
||||
end |
||||
|
||||
let(:users) { [kathmandu_user] } |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user who is in a 45 min time zone and having reminder set to 8:00 and being executed at 7:55' do |
||||
let(:kathmandu_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Kathmandu', |
||||
preferences: { |
||||
time_zone: "Kathmandu", |
||||
daily_reminders: { |
||||
enabled: true, |
||||
times: [hitting_reminder_slot_for("Asia/Kathmandu", current_utc_time)] |
||||
} |
||||
} |
||||
) |
||||
end |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['Asia/Kathmandu'].parse("07:55").utc } |
||||
let(:scope_time) { ActiveSupport::TimeZone['Asia/Kathmandu'].parse("07:45").utc } |
||||
let(:notifications) do |
||||
FactoryBot.create(:notification, recipient: kathmandu_user, created_at: 5.minutes.ago) |
||||
end |
||||
|
||||
let(:users) { [kathmandu_user] } |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time but with an already read notification (IAN)' do |
||||
let(:notifications) do |
||||
FactoryBot.create(:notification, recipient: paris_user, created_at: 5.minutes.ago, read_ian: true) |
||||
end |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time but with an already read notification (reminder)' do |
||||
let(:notifications) do |
||||
FactoryBot.create(:notification, recipient: paris_user, created_at: 5.minutes.ago, read_mail_digest: true) |
||||
end |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is matching the configured time but with the user being inactive' do |
||||
let(:notifications) do |
||||
FactoryBot.create(:notification, recipient: paris_user, created_at: 5.minutes.ago) |
||||
end |
||||
|
||||
before do |
||||
paris_user.locked! |
||||
end |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is before the configured time' do |
||||
let(:paris_user_daily_reminders) do |
||||
{ |
||||
enabled: true, |
||||
times: [hitting_reminder_slot_for("Paris", current_utc_time + 1.hour)] |
||||
} |
||||
end |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user whose local time is after the configured time' do |
||||
let(:paris_user_daily_reminders) do |
||||
{ |
||||
enabled: true, |
||||
times: [hitting_reminder_slot_for("Paris", current_utc_time - 1.hour)] |
||||
} |
||||
end |
||||
|
||||
it 'is empty' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'for a user without a time zone' do |
||||
let(:paris_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Paris', |
||||
preferences: { |
||||
daily_reminders: { |
||||
enabled: true, |
||||
times: [hitting_reminder_slot_for("UTC", current_utc_time)] |
||||
} |
||||
} |
||||
) |
||||
end |
||||
|
||||
it 'is including the user as UTC is assumed' do |
||||
expect(scope) |
||||
.to match_array([paris_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user without a time zone and daily_reminders at 08:00' do |
||||
let(:paris_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Paris', |
||||
preferences: {} |
||||
) |
||||
end |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['UTC'].parse("08:00").utc } |
||||
|
||||
it 'is including the user as UTC at 08:00 is assumed' do |
||||
expect(scope) |
||||
.to match_array([paris_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user without a time zone and daily_reminders at 10:00' do |
||||
let(:paris_user) do |
||||
FactoryBot.create( |
||||
:user, |
||||
firstname: 'Paris', |
||||
preferences: {} |
||||
) |
||||
end |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['UTC'].parse("10:00").utc } |
||||
let(:scope_time) { ActiveSupport::TimeZone['UTC'].parse("10:00").utc } |
||||
|
||||
it 'is empty as UTC at 08:00 is assumed' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
|
||||
context 'when the provided scope_time is after the current time' do |
||||
let(:scope_time) { Time.current + 1.minute } |
||||
|
||||
it 'raises an error' do |
||||
expect { scope } |
||||
.to raise_error ArgumentError |
||||
end |
||||
end |
||||
|
||||
context 'for a user without preferences at 08:00' do |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['UTC'].parse("08:00").utc } |
||||
let(:scope_time) { ActiveSupport::TimeZone['UTC'].parse("08:00").utc } |
||||
|
||||
before do |
||||
paris_user.pref.destroy |
||||
end |
||||
|
||||
it 'is including the user as UTC at 08:00 is assumed' do |
||||
expect(scope) |
||||
.to match_array([paris_user]) |
||||
end |
||||
end |
||||
|
||||
context 'for a user without preferences at 10:00' do |
||||
let(:current_utc_time) { ActiveSupport::TimeZone['UTC'].parse("10:00").utc } |
||||
let(:scope_time) { ActiveSupport::TimeZone['UTC'].parse("10:00").utc } |
||||
|
||||
before do |
||||
paris_user.pref.destroy |
||||
end |
||||
|
||||
it 'is empty as UTC at 08:00 is assumed' do |
||||
expect(scope) |
||||
.to be_empty |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,33 @@ |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2021 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. |
||||
#++ |
||||
|
||||
## |
||||
# Calculates a slot in the user's local time that hits for scheduling reminder mail jobs |
||||
def hitting_reminder_slot_for(time_zone, current_utc_time = Time.current.getutc) |
||||
current_utc_time.in_time_zone(ActiveSupport::TimeZone[time_zone]).strftime('%H:00:00+00:00') |
||||
end |
@ -0,0 +1,101 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2021 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::ScheduleReminderMailsJob, type: :job do |
||||
subject(:job) { scheduled_job.invoke_job } |
||||
|
||||
let(:scheduled_job) do |
||||
described_class.ensure_scheduled! |
||||
|
||||
Delayed::Job.first |
||||
end |
||||
|
||||
let(:ids) { [23, 42] } |
||||
let(:run_at) { scheduled_job.run_at } |
||||
|
||||
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) |
||||
|
||||
scheduled_job.update_column(:run_at, run_at) |
||||
|
||||
scope = instance_double(ActiveRecord::Relation) |
||||
allow(User) |
||||
.to receive(:having_reminder_mail_to_send) |
||||
.and_return(scope) |
||||
|
||||
allow(scope) |
||||
.to receive(:pluck) |
||||
.with(:id) |
||||
.and_return(ids) |
||||
end |
||||
|
||||
describe '#perform' do |
||||
shared_examples_for 'schedules reminder mails' do |
||||
it 'schedules reminder jobs for every user with a reminder mails to be sent' do |
||||
expect { subject } |
||||
.to change(Delayed::Job, :count) |
||||
.by(2) |
||||
|
||||
jobs = Delayed::Job.all.map do |job| |
||||
YAML.safe_load(job.handler, permitted_classes: [ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper]) |
||||
end |
||||
|
||||
reminder_jobs = jobs.select { |job| job.job_data['job_class'] == "Mails::ReminderJob" } |
||||
|
||||
expect(reminder_jobs[0].job_data['arguments']) |
||||
.to match_array([23]) |
||||
|
||||
expect(reminder_jobs[1].job_data['arguments']) |
||||
.to match_array([42]) |
||||
end |
||||
|
||||
it 'queries with the intended job execution time (which might have been missed due to high load)' do |
||||
subject |
||||
|
||||
expect(User) |
||||
.to have_received(:having_reminder_mail_to_send) |
||||
.with(run_at) |
||||
end |
||||
end |
||||
|
||||
it_behaves_like 'schedules reminder mails' |
||||
|
||||
context 'with a job that missed some runs' do |
||||
let(:run_at) { scheduled_job.run_at - 3.hours } |
||||
|
||||
it_behaves_like 'schedules reminder mails' |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue