Merge pull request #9611 from opf/implementation/38707-reminder-emails-scheduling

Schedule reminder mails on a CRON like delayed job
pull/9699/head
ulferts 3 years ago committed by GitHub
commit 87ed8ab4d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      app/models/notification.rb
  2. 7
      app/models/notifications/scopes/unsent_reminders_before.rb
  3. 2
      app/models/project.rb
  4. 3
      app/models/user.rb
  5. 132
      app/models/users/scopes/having_reminder_mail_to_send.rb
  6. 8
      app/views/admin/settings/mail_notifications_settings/show.html.erb
  7. 35
      app/workers/mails/reminder_job.rb
  8. 46
      app/workers/notifications/schedule_reminder_mails_job.rb
  9. 8
      app/workers/notifications/workflow_job.rb
  10. 3
      config/locales/en.yml
  11. 2
      config/settings.yml
  12. 18
      db/migrate/20210915154656_add_user_preference_settings_indices.rb
  13. 77
      spec/features/notifications/digest_mail_spec.rb
  14. 96
      spec/features/notifications/reminder_mail_spec.rb
  15. 24
      spec/models/notifications/scopes/unsent_reminders_before_spec.rb
  16. 465
      spec/models/users/scopes/having_reminder_mail_to_send_spec.rb
  17. 33
      spec/support/schedule_reminder_mails.rb
  18. 4
      spec/workers/mails/reminder_job_spec.rb
  19. 101
      spec/workers/notifications/schedule_reminder_mails_job_spec.rb
  20. 17
      spec/workers/notifications/workflow_job_spec.rb

@ -23,7 +23,7 @@ class Notification < ApplicationRecord
belongs_to :resource, polymorphic: true
include Scopes::Scoped
scopes :mail_digest_before,
scopes :unsent_reminders_before,
:unread_mail,
:unread_mail_digest,
:recipient

@ -29,15 +29,16 @@
#++
module Notifications::Scopes
module MailDigestBefore
module UnsentRemindersBefore
extend ActiveSupport::Concern
class_methods do
# Return notifications of the user for which mail digest is to be sent and that is created before
# Return notifications for the user for who email reminders shall be sent and that were created before
# the specified time.
def mail_digest_before(recipient:, time:)
def unsent_reminders_before(recipient:, time:)
where(Notification.arel_table[:created_at].lteq(time))
.where(recipient: recipient)
.where("read_ian IS NULL OR read_ian IS FALSE")
.where(read_mail_digest: false)
end
end

@ -44,7 +44,7 @@ class Project < ApplicationRecord
has_many :members, -> {
# TODO: check whether this should
# remaint to be limited to User only
# remain to be limited to User only
includes(:principal, :roles)
.merge(Principal.not_locked.user)
.references(:principal, :roles)

@ -76,7 +76,8 @@ class User < Principal
scopes :find_by_login,
:newest,
:notified_on_all,
:watcher_recipients
:watcher_recipients,
:having_reminder_mail_to_send
def self.create_blocked_scope(scope, blocked)
scope.where(blocked_condition(blocked))

@ -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

@ -37,14 +37,6 @@ See COPYRIGHT and LICENSE files for more details.
<div class="form--field"><%= setting_text_field :mail_from, size: 60, container_class: '-middle' %></div>
<div class="form--field"><%= setting_check_box :bcc_recipients %></div>
<div class="form--field"><%= setting_check_box :plain_text_mail %></div>
<div class="form--field">
<%= setting_time_field :notification_email_digest_time,
container_class: '-xslim',
unit: 'UTC' %>
<div class="form--field-instructions">
<%= t(:'settings.notifications.email_digest_explanation') %>
</div>
</div>
</section>
<fieldset id="emails_decorators" class="form--fieldset"><legend class="form--fieldset-legend"><%= t(:setting_emails_header) %> & <%= t(:setting_emails_footer) %></legend>

@ -28,42 +28,13 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Mails::DigestJob < Mails::DeliverJob
class << self
def schedule(notification)
# This alone is vulnerable to the edge case of the Mails::DigestJob
# having started but not completed when a new digest notification is generated.
# To cope with it, the Mails::DigestJob as its first action sets all digest notifications
# to being handled even though they are still processed.
return if digest_job_already_scheduled?(notification)
set(wait_until: execution_time(notification.recipient))
.perform_later(notification.recipient)
end
private
def execution_time(user)
zone = (user.time_zone || ActiveSupport::TimeZone.new('UTC'))
zone.parse(Setting.notification_email_digest_time) + 1.day
end
def digest_job_already_scheduled?(notification)
Notification
.mail_digest_before(recipient: notification.recipient,
time: notification.created_at)
.where.not(id: notification.id)
.exists?
end
end
class Mails::ReminderJob < Mails::DeliverJob
private
def render_mail
# Have to cast to array since the update in the subsequent block
# will result in the notification to not be found via the .mail_digest_before scope.
notification_ids = Notification.mail_digest_before(recipient: recipient, time: Time.current).pluck(:id)
# will result in the notification to not be found via the .unsent_reminders_before scope.
notification_ids = Notification.unsent_reminders_before(recipient: recipient, time: Time.current).pluck(:id)
return nil if notification_ids.empty?

@ -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

@ -71,13 +71,5 @@ class Notifications::WorkflowJob < ApplicationJob
.new(notification)
.call
end
Notification
.where(id: notification_ids)
.unread_mail_digest
.each do |notification|
Mails::DigestJob
.schedule(notification)
end
end
end

@ -905,7 +905,6 @@ en:
firstname: "First name"
group: "Group"
groups: "Groups"
name: "Group name"
id: "ID"
is_default: "Default value"
is_for_all: "For all projects"
@ -2453,7 +2452,6 @@ en:
setting_enabled_scm: "Enabled SCM"
setting_enabled_projects_columns: "Visible in project list"
setting_notification_retention_period_days: "Notification retention period"
setting_notification_email_digest_time: "Email digest time"
setting_feeds_enabled: "Enable Feeds"
setting_feeds_limit: "Feed content limit"
setting_file_max_size_displayed: "Max size of text files displayed inline"
@ -2535,7 +2533,6 @@ en:
retention_text: >
Set the number of days notification events for users (the source for in-app notifications)
will be kept in the system. Any events older than this time will be deleted.
email_digest_explanation: "Once a day, an email digest can be sent out containing a collection of all the notification users subscribed to. The setting is relative to each users configured time zone, so e.g. 8:00 will be executed at 7:00 UTC for users in UTC+1 and 9:00 UTC for those in UTC-1."
events_explanation: 'Governs for which event an email is sent out. Work packages are excluded from this list as the notifications for them can be configured specifically for every user.'
display:
first_date_of_week_and_year_set: >

@ -382,5 +382,3 @@ apiv3_docs_enabled:
notification_retention_period_days:
default: 30
format: int
notification_email_digest_time:
default: '08:00'

@ -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

@ -41,7 +41,7 @@ describe "Reminder email", type: :feature, js: true do
end
end
context 'with the my page' do
context 'when configuring via the my page' do
let(:reminders_settings_page) { Pages::My::Reminders.new(current_user) }
current_user do
@ -51,7 +51,7 @@ describe "Reminder email", type: :feature, js: true do
it_behaves_like 'reminder settings'
end
context 'with the user administration page' do
context 'when configuring via the user administration page' do
let(:reminders_settings_page) { Pages::Reminders::Settings.new(other_user) }
let(:other_user) { FactoryBot.create :user }
@ -62,4 +62,96 @@ describe "Reminder email", type: :feature, js: true do
it_behaves_like 'reminder settings'
end
describe 'sending' do
let!(:project) { FactoryBot.create :project, members: { current_user => role } }
let!(:mute_project) { FactoryBot.create :project, members: { current_user => role } }
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) }
# The run_at time of the delayed job used for scheduling the reminder mails
# needs to be within a time frame eligible for sending out mails for the chose
# time zone. For the time zone Hawaii (UTC-10) this means between 8:00:00 and 8:14:59 UTC.
# The job is scheduled to run every 15 min so the run_at will in production always move between the quarters of an hour.
# The current time can be way behind that.
let(:current_utc_time) { ActiveSupport::TimeZone['Hawaii'].parse("08:34:10").utc }
let(:job_run_at) { ActiveSupport::TimeZone['Hawaii'].parse("08:00").utc }
current_user do
FactoryBot.create(
:user,
preferences: {
time_zone: "Hawaii",
daily_reminders: {
enabled: true,
times: [hitting_reminder_slot_for("Hawaii", current_utc_time)]
}
},
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: false)
]
)
end
before do
allow(Time).to receive(:current).and_return(current_utc_time)
allow(Time).to receive(:now).and_return(current_utc_time)
watched_work_package
work_package
involved_work_package
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
# There is no delayed_job associated when using the testing backend of ActiveJob
# so we have to mock it.
allow(Notifications::ScheduleReminderMailsJob)
.to receive(:delayed_job)
.and_return(instance_double(Delayed::Backend::ActiveRecord::Job, run_at: job_run_at))
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
# The Job is triggered by time so we mock it and the jobs started by it being triggered
Notifications::ScheduleReminderMailsJob.perform_later
2.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
end

@ -28,9 +28,9 @@
require 'spec_helper'
describe Notifications::Scopes::MailDigestBefore, type: :model do
describe '.mail_digest_before' do
subject(:scope) { ::Notification.mail_digest_before(recipient: recipient, time: time) }
describe Notifications::Scopes::UnsentRemindersBefore, type: :model do
describe '.unsent_reminders_before' do
subject(:scope) { ::Notification.unsent_reminders_before(recipient: recipient, time: time) }
let(:recipient) do
FactoryBot.create(:user)
@ -42,10 +42,12 @@ describe Notifications::Scopes::MailDigestBefore, type: :model do
let(:notification) do
FactoryBot.create(:notification,
recipient: notification_recipient,
read_ian: notification_read_ian,
read_mail_digest: notification_read_mail_digest,
created_at: notification_created_at)
end
let(:notification_read_mail_digest) { false }
let(:notification_read_ian) { false }
let(:notification_created_at) { Time.current - 10.minutes }
let(:notification_recipient) { recipient }
@ -58,35 +60,41 @@ describe Notifications::Scopes::MailDigestBefore, type: :model do
end
end
context 'with a notification of the user for mail digests before the time' do
context 'with a unread and not reminded notification that was created before the time and for the user' do
it 'returns the notification' do
expect(scope)
.to match_array([notification])
end
end
context 'with a notification of the user for mail digests after the time' do
context 'with a unread and not reminded notification that was created after the time and for the user' do
let(:notification_created_at) { Time.current + 10.minutes }
it_behaves_like 'is empty'
end
context 'with a notification of a different user for mail digests before the time' do
context 'with a unread and not reminded notification that was created before the time and for different user' do
let(:notification_recipient) { FactoryBot.create(:user) }
it_behaves_like 'is empty'
end
context 'with a notification of a different user not for mail digests before the time' do
context 'with a unread and not reminded notification created before the time and for the user' do
let(:notification_read_mail_digest) { nil }
it_behaves_like 'is empty'
end
context 'with a notification of a different user for already covered mail digests before the time' do
context 'with a unread but reminded notification created before the time and for the user' do
let(:notification_read_mail_digest) { true }
it_behaves_like 'is empty'
end
context 'with a read notification that was created before the time' do
let(:notification_read_ian) { true }
it_behaves_like 'is empty'
end
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

@ -30,7 +30,7 @@
require 'spec_helper'
describe Mails::DigestJob, type: :model do
describe Mails::ReminderJob, type: :model do
subject(:job) { described_class.perform_now(recipient) }
let(:recipient) do
@ -46,7 +46,7 @@ describe Mails::DigestJob, type: :model do
.and_return(Time.current)
allow(Notification)
.to receive(:mail_digest_before)
.to receive(:unsent_reminders_before)
.with(recipient: recipient, time: Time.current)
.and_return(notifications)

@ -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

@ -108,15 +108,8 @@ describe Notifications::WorkflowJob, type: :model do
service_instance
end
let!(:digest_job) do
allow(Mails::DigestJob)
.to receive(:schedule)
end
before do
scope = class_double(Notification,
unread_mail: [notifications.first],
unread_mail_digest: [notifications.last])
scope = class_double(Notification, unread_mail: [notifications.first])
allow(Notification)
.to receive(:where)
@ -130,14 +123,6 @@ describe Notifications::WorkflowJob, type: :model do
expect(mail_service)
.to have_received(:call)
end
it 'schedules a digest job for all notifications that are marked for the digest' do
perform_job
expect(Mails::DigestJob)
.to have_received(:schedule)
.with(notifications.last)
end
end
end
end

Loading…
Cancel
Save