Skip to content

Instantly share code, notes, and snippets.

@aa6my
Created August 11, 2025 05:13
Show Gist options
  • Select an option

  • Save aa6my/cddaa3bb215dd328430e9842200bb66b to your computer and use it in GitHub Desktop.

Select an option

Save aa6my/cddaa3bb215dd328430e9842200bb66b to your computer and use it in GitHub Desktop.
Fixing NoMethodError (undefined method `run_at' for nil:NilClass)
alerts_spec.rb
require 'spec_helper'
require 'features/page_objects/notification'
# rubocop:disable RSpec/ScatteredLet
describe "Notification center date alerts", js: true, with_settings: { journal_aggregation_time_minutes: 0 } do
include ActiveSupport::Testing::TimeHelpers
shared_let(:time_zone) { ActiveSupport::TimeZone['Europe/Berlin'] }
shared_let(:user) do
create(:user, preferences: { time_zone: time_zone.name }).tap do |user|
user.notification_settings.first.update(
start_date: 7,
due_date: 3,
overdue: 1
)
end
end
shared_let(:project) { create(:project_with_types) }
shared_let(:role) { create(:role, permissions: %i[view_work_packages edit_work_packages work_package_assigned]) }
shared_let(:membership) { create(:member, principal: user, project:, roles: [role]) }
shared_let(:milestone_type) { create(:type_milestone) }
def create_alertable(**attributes)
attributes = attributes.reverse_merge(assigned_to: user, project:)
work_package = create(:work_package, **attributes)
# TimeCop sets the current time to 1:04h below. To be compatible to historic searches,
# we need to pretend that the journal records have been created before that time.
# https://github.com/opf/openproject/pull/11678#issuecomment-1328011996
#
work_package.journals.update_all created_at: Time.zone.now.change(hour: 0, minute: 0)
work_package
end
# notification will be created by the job because `overdue: 1` in user notifications settings
shared_let(:milestone_wp_past) do
create_alertable(subject: 'Milestone WP past', type: milestone_type, due_date: time_zone.today - 2.days)
end
shared_let(:milestone_wp_future) do
create_alertable(subject: 'Milestone WP future', type: milestone_type, due_date: time_zone.today + 1.day)
end
shared_let(:wp_start_past) do
create_alertable(subject: 'WP start past', start_date: time_zone.today - 1.day)
end
# notification will be created by job because `start_date: 7` in user notifications settings
shared_let(:wp_start_future) do
create_alertable(subject: 'WP start future', start_date: time_zone.today + 7.days)
end
# notification will be created by job because `overdue: 1` in user notifications settings
shared_let(:wp_due_past) do
create_alertable(subject: 'WP due past', due_date: time_zone.today - 3.days)
end
# notification will be created by job because `due_date: 3` in user notifications settings
shared_let(:wp_due_future) do
create_alertable(subject: 'WP due future', due_date: time_zone.today + 3.days)
end
shared_let(:wp_double_notification) do
create_alertable(subject: 'Alert + Mention', due_date: time_zone.today + 1.day)
end
shared_let(:wp_unset_date) do
create_alertable(subject: 'Unset date', due_date: nil)
end
shared_let(:wp_due_today) do
create_alertable(subject: 'Due today', due_date: time_zone.today)
end
shared_let(:wp_double_alert) do
create_alertable(subject: 'Double alert', start_date: time_zone.today - 1.day, due_date: time_zone.today + 1.day)
end
# notification created by CreateDateAlertsNotificationsJob
let(:notification_milestone_past) do
Notification.find_by(reason: 'date_alert_due_date', resource: milestone_wp_past)
end
shared_let(:notification_milestone_future) do
create(:notification,
reason: :date_alert_due_date,
recipient: user,
resource: milestone_wp_future,
project:)
end
shared_let(:notification_wp_start_past) do
create(:notification,
reason: :date_alert_start_date,
recipient: user,
resource: wp_start_past,
project:)
end
# notification created by CreateDateAlertsNotificationsJob
let(:notification_wp_start_future) do
Notification.find_by(reason: 'date_alert_start_date', resource: wp_start_future)
end
# notification created by CreateDateAlertsNotificationsJob
let(:notification_wp_due_past) do
Notification.find_by(reason: 'date_alert_due_date', resource: wp_due_past)
end
# notification created by CreateDateAlertsNotificationsJob
let(:notification_wp_due_future) do
Notification.find_by(reason: 'date_alert_due_date', resource: wp_due_future)
end
shared_let(:notification_wp_double_date_alert) do
create(:notification,
reason: :date_alert_due_date,
recipient: user,
resource: wp_double_notification,
project:)
end
shared_let(:notification_wp_double_mention) do
create(:notification,
reason: :mentioned,
recipient: user,
resource: wp_double_notification,
project:)
end
shared_let(:notification_wp_double_alerts) do
due = create(:notification,
reason: :date_alert_due_date,
recipient: user,
resource: wp_double_alert,
project:)
start = create(:notification,
reason: :date_alert_start_date,
recipient: user,
resource: wp_double_alert,
project:)
[start, due]
end
shared_let(:notification_wp_unset_date) do
create(:notification,
reason: :date_alert_due_date,
recipient: user,
resource: wp_unset_date,
project:)
end
shared_let(:notification_wp_due_today) do
create(:notification,
reason: :date_alert_due_date,
recipient: user,
resource: wp_due_today,
project:)
end
let(:center) { Pages::Notifications::Center.new }
let(:side_menu) { Components::Notifications::Sidemenu.new }
let(:toaster) { PageObjects::Notifications.new(page) }
let(:activity_tab) { Components::WorkPackages::Activities.new(notification_wp_due_today) }
# 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_create_date_alerts_notifications_job
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
travel_back
end
before do
run_create_date_alerts_notifications_job
perform_enqueued_jobs
login_as user
visit notifications_center_path
end
context 'without date alerts ee' do
it 'shows the upsale page' do
side_menu.click_item 'Date alert'
expect(page).to have_current_path /notifications\/date_alerts/
expect(page).to have_text 'Date alerts is an Enterprise'
expect(page).to have_text 'Please upgrade to a paid plan '
# It does not allows direct url access
visit notifications_center_path(filter: 'reason', name: 'dateAlert')
toaster.expect_error('Filters Reason filter has invalid values.')
end
end
context 'with date alerts ee', with_ee: %i[date_alerts] do
it 'shows the date alerts according to specification' do
center.expect_item(notification_wp_start_past, 'Start date was 1 day ago')
center.expect_item(notification_wp_start_future, 'Start date is in 7 days')
center.expect_item(notification_wp_due_past, 'Overdue since 3 days')
center.expect_item(notification_wp_due_future, 'Finish date is in 3 days')
center.expect_item(notification_milestone_past, 'Overdue since 2 days')
center.expect_item(notification_milestone_future, 'Milestone date is in 1 day')
center.expect_item(notification_wp_unset_date, 'Finish date is deleted')
center.expect_item(notification_wp_due_today, 'Finish date is today')
# Doesn't show the date alert for the mention, not the alert
center.expect_item(notification_wp_double_mention, /(seconds|minutes) ago by Anonymous/)
center.expect_no_item(notification_wp_double_date_alert)
# When switch to date alerts, it shows the alert, no longer the mention
side_menu.click_item 'Date alert'
center.expect_item(notification_wp_double_date_alert, 'Finish date is in 1 day')
center.expect_no_item(notification_wp_double_mention)
# Ensure that start is created later than due for implicit ID sorting
double_alert_start, double_alert_due = notification_wp_double_alerts
expect(double_alert_start.id).to be > double_alert_due.id
# We see that start is actually the newest ID, hence shown as the primary notification
# but the date alert still shows the finish date
center.expect_item(double_alert_start, 'Finish date is in 1 day')
center.expect_no_item(double_alert_due)
# Opening a date alert opens in overview
center.click_item notification_wp_start_past
split_screen = Pages::SplitWorkPackage.new wp_start_past
split_screen.expect_tab :overview
# We expect no badge count
activity_tab.expect_no_notification_badge
# The same is true for the mention item that is opened in date alerts filter
center.click_item notification_wp_double_date_alert
split_screen = Pages::SplitWorkPackage.new wp_double_notification
split_screen.expect_tab :overview
# We expect one badge
activity_tab.expect_notification_count 1
# When a work package is updated to a different date
wp_double_notification.update_column(:due_date, 5.days.from_now)
page.driver.refresh
center.expect_item(notification_wp_double_date_alert, 'Finish date is in 5 days')
center.expect_no_item(notification_wp_double_mention)
end
end
end
# rubocop:enable RSpec/ScatteredLet
#-- 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
context 'with a user having only due_date active in notification settings' do
before do
NotificationSetting
.where(user: user_paris)
.update_all(due_date: 1,
start_date: nil,
overdue: nil)
end
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 'with a user having only start_date active in notification settings' do
before do
NotificationSetting
.where(user: user_paris)
.update_all(due_date: nil,
start_date: 1,
overdue: nil)
end
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 'with a user having only overdue active in notification settings' do
before do
NotificationSetting
.where(user: user_paris)
.update_all(due_date: nil,
start_date: nil,
overdue: 1)
end
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 'without a user having notification settings' do
before do
NotificationSetting
.where(user: user_paris)
.update_all(due_date: nil,
start_date: nil,
overdue: nil)
end
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:00' }
end
end
context 'with a user having only a project active notification settings' do
before do
NotificationSetting
.where(user: user_paris)
.update_all(due_date: nil,
start_date: nil,
overdue: nil)
NotificationSetting
.create(user: user_paris,
project: create(:project),
due_date: 1,
start_date: nil,
overdue: nil)
end
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 'with a locked user' do
before do
user_paris.locked!
end
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:00' }
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment