Created
August 11, 2025 05:13
-
-
Save aa6my/cddaa3bb215dd328430e9842200bb66b to your computer and use it in GitHub Desktop.
Fixing NoMethodError (undefined method `run_at' for nil:NilClass)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #-- 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