activity_notification 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/build.yml +9 -36
- data/CHANGELOG.md +26 -1
- data/Gemfile +1 -1
- data/README.md +9 -1
- data/activity_notification.gemspec +5 -5
- data/ai-curated-specs/issues/172/design.md +220 -0
- data/ai-curated-specs/issues/172/tasks.md +326 -0
- data/ai-curated-specs/issues/188/design.md +227 -0
- data/ai-curated-specs/issues/188/requirements.md +78 -0
- data/ai-curated-specs/issues/188/tasks.md +203 -0
- data/ai-curated-specs/issues/188/upstream-contributions.md +592 -0
- data/ai-curated-specs/issues/50/design.md +235 -0
- data/ai-curated-specs/issues/50/requirements.md +49 -0
- data/ai-curated-specs/issues/50/tasks.md +232 -0
- data/app/controllers/activity_notification/notifications_api_controller.rb +22 -0
- data/app/controllers/activity_notification/notifications_controller.rb +27 -1
- data/app/mailers/activity_notification/mailer.rb +2 -2
- data/app/views/activity_notification/notifications/default/_index.html.erb +6 -1
- data/app/views/activity_notification/notifications/default/destroy_all.js.erb +6 -0
- data/docs/Setup.md +43 -6
- data/gemfiles/Gemfile.rails-7.0 +2 -0
- data/gemfiles/Gemfile.rails-7.2 +0 -2
- data/gemfiles/Gemfile.rails-8.0 +24 -0
- data/lib/activity_notification/apis/notification_api.rb +51 -2
- data/lib/activity_notification/controllers/concerns/swagger/notifications_api.rb +59 -0
- data/lib/activity_notification/helpers/view_helpers.rb +28 -0
- data/lib/activity_notification/mailers/helpers.rb +14 -7
- data/lib/activity_notification/models/concerns/target.rb +16 -0
- data/lib/activity_notification/models.rb +1 -1
- data/lib/activity_notification/notification_resilience.rb +115 -0
- data/lib/activity_notification/orm/dynamoid/extension.rb +4 -87
- data/lib/activity_notification/orm/dynamoid/notification.rb +19 -2
- data/lib/activity_notification/orm/dynamoid.rb +42 -6
- data/lib/activity_notification/rails/routes.rb +3 -2
- data/lib/activity_notification/version.rb +1 -1
- data/lib/activity_notification.rb +1 -0
- data/lib/generators/templates/controllers/notifications_api_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_api_with_devise_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_with_devise_controller.rb +5 -0
- data/spec/concerns/apis/notification_api_spec.rb +161 -5
- data/spec/concerns/models/target_spec.rb +7 -0
- data/spec/controllers/controller_spec_utility.rb +1 -1
- data/spec/controllers/notifications_api_controller_shared_examples.rb +113 -0
- data/spec/controllers/notifications_controller_shared_examples.rb +150 -0
- data/spec/helpers/view_helpers_spec.rb +14 -0
- data/spec/jobs/notification_resilience_job_spec.rb +167 -0
- data/spec/mailers/notification_resilience_spec.rb +263 -0
- data/spec/models/notification_spec.rb +1 -1
- data/spec/models/subscription_spec.rb +1 -1
- data/spec/rails_app/app/helpers/devise_helper.rb +2 -0
- data/spec/rails_app/config/application.rb +1 -0
- data/spec/rails_app/config/initializers/zeitwerk.rb +10 -0
- metadata +67 -53
@@ -359,6 +359,156 @@ shared_examples_for :notifications_controller do
|
|
359
359
|
expect(@notification_2.reload.opened?).to be_truthy
|
360
360
|
end
|
361
361
|
end
|
362
|
+
|
363
|
+
context 'with ids parameter' do
|
364
|
+
it "opens only specified notifications" do
|
365
|
+
post_with_compatibility :open_all, target_params.merge({ typed_target_param => test_target, ids: [@notification_1.id] }), valid_session
|
366
|
+
expect(@notification_1.reload.opened?).to be_truthy
|
367
|
+
expect(@notification_2.reload.opened?).to be_falsey
|
368
|
+
end
|
369
|
+
|
370
|
+
it "applies other filter options when ids are specified" do
|
371
|
+
post_with_compatibility :open_all, target_params.merge({
|
372
|
+
typed_target_param => test_target,
|
373
|
+
ids: [@notification_1.id],
|
374
|
+
filtered_by_key: 'non_existent_key'
|
375
|
+
}), valid_session
|
376
|
+
expect(@notification_1.reload.opened?).to be_falsey
|
377
|
+
expect(@notification_2.reload.opened?).to be_falsey
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
describe "POST #destroy_all" do
|
384
|
+
context "http direct POST request" do
|
385
|
+
before do
|
386
|
+
@notification = create(:notification, target: test_target)
|
387
|
+
expect(test_target.notifications.count).to eq(1)
|
388
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target }), valid_session
|
389
|
+
end
|
390
|
+
|
391
|
+
it "returns 302 as http status code" do
|
392
|
+
expect(response.status).to eq(302)
|
393
|
+
end
|
394
|
+
|
395
|
+
it "destroys all notifications of the target" do
|
396
|
+
expect(test_target.notifications.count).to eq(0)
|
397
|
+
end
|
398
|
+
|
399
|
+
it "redirects to :index" do
|
400
|
+
expect(response).to redirect_to action: :index
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
context "http POST request from root_path" do
|
405
|
+
before do
|
406
|
+
@notification = create(:notification, target: test_target)
|
407
|
+
expect(test_target.notifications.count).to eq(1)
|
408
|
+
request.env["HTTP_REFERER"] = root_path
|
409
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target }), valid_session
|
410
|
+
end
|
411
|
+
|
412
|
+
it "returns 302 as http status code" do
|
413
|
+
expect(response.status).to eq(302)
|
414
|
+
end
|
415
|
+
|
416
|
+
it "destroys all notifications of the target" do
|
417
|
+
expect(test_target.notifications.count).to eq(0)
|
418
|
+
end
|
419
|
+
|
420
|
+
it "redirects to root_path as request.referer" do
|
421
|
+
expect(response).to redirect_to root_path
|
422
|
+
end
|
423
|
+
end
|
424
|
+
|
425
|
+
context "Ajax POST request" do
|
426
|
+
before do
|
427
|
+
@notification = create(:notification, target: test_target)
|
428
|
+
expect(test_target.notifications.count).to eq(1)
|
429
|
+
xhr_with_compatibility :post, :destroy_all, target_params.merge({ typed_target_param => test_target }), valid_session
|
430
|
+
end
|
431
|
+
|
432
|
+
it "returns 200 as http status code" do
|
433
|
+
expect(response.status).to eq(200)
|
434
|
+
end
|
435
|
+
|
436
|
+
it "assigns notification index as @notifications" do
|
437
|
+
expect(assigns(:notifications)).to eq([])
|
438
|
+
end
|
439
|
+
|
440
|
+
it "destroys all notifications of the target" do
|
441
|
+
expect(test_target.notifications.count).to eq(0)
|
442
|
+
end
|
443
|
+
|
444
|
+
it "renders the :destroy_all template as format js" do
|
445
|
+
expect(response).to render_template :destroy_all, format: :js
|
446
|
+
end
|
447
|
+
end
|
448
|
+
|
449
|
+
context "with filter request parameters" do
|
450
|
+
before do
|
451
|
+
@target_1, @notifiable_1, @group_1, @key_1 = create(:confirmed_user), create(:article), nil, "key.1"
|
452
|
+
@target_2, @notifiable_2, @group_2, @key_2 = create(:confirmed_user), create(:comment), @notifiable_1, "key.2"
|
453
|
+
@notification_1 = create(:notification, target: test_target, notifiable: @notifiable_1, group: @group_1, key: @key_1)
|
454
|
+
@notification_2 = create(:notification, target: test_target, notifiable: @notifiable_2, group: @group_2, key: @key_2, created_at: @notification_1.created_at + 10.second)
|
455
|
+
expect(test_target.notifications.count).to eq(2)
|
456
|
+
end
|
457
|
+
|
458
|
+
context "with filtered_by_type request parameters" do
|
459
|
+
it "destroys filtered notifications only" do
|
460
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target, 'filtered_by_type' => @notifiable_2.to_class_name }), valid_session
|
461
|
+
expect(test_target.notifications.count).to eq(1)
|
462
|
+
expect(test_target.notifications.first).to eq(@notification_1)
|
463
|
+
end
|
464
|
+
end
|
465
|
+
|
466
|
+
context "with filtered_by_group request parameters" do
|
467
|
+
it "destroys filtered notifications only" do
|
468
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target, 'filtered_by_group_type' => @group_2.to_class_name, 'filtered_by_group_id' => @group_2.id.to_s }), valid_session
|
469
|
+
expect(test_target.notifications.count).to eq(1)
|
470
|
+
expect(test_target.notifications.first).to eq(@notification_1)
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
474
|
+
context "with filtered_by_key request parameters" do
|
475
|
+
it "destroys filtered notifications only" do
|
476
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target, 'filtered_by_key' => @key_2 }), valid_session
|
477
|
+
expect(test_target.notifications.count).to eq(1)
|
478
|
+
expect(test_target.notifications.first).to eq(@notification_1)
|
479
|
+
end
|
480
|
+
end
|
481
|
+
|
482
|
+
context "with later_than request parameters" do
|
483
|
+
it "destroys filtered notifications only" do
|
484
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target, 'later_than' => (@notification_1.created_at.in_time_zone + 5.second).iso8601(3) }), valid_session
|
485
|
+
expect(test_target.notifications.count).to eq(1)
|
486
|
+
expect(test_target.notifications.first).to eq(@notification_1)
|
487
|
+
end
|
488
|
+
end
|
489
|
+
|
490
|
+
context "with earlier_than request parameters" do
|
491
|
+
it "destroys filtered notifications only" do
|
492
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target, 'earlier_than' => (@notification_2.created_at.in_time_zone - 5.second).iso8601(3) }), valid_session
|
493
|
+
expect(test_target.notifications.count).to eq(1)
|
494
|
+
expect(test_target.notifications.first).to eq(@notification_2)
|
495
|
+
end
|
496
|
+
end
|
497
|
+
|
498
|
+
context "with ids request parameters" do
|
499
|
+
it "destroys notifications with specified IDs only" do
|
500
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target, 'ids' => [@notification_2.id.to_s] }), valid_session
|
501
|
+
expect(test_target.notifications.count).to eq(1)
|
502
|
+
expect(test_target.notifications.first).to eq(@notification_1)
|
503
|
+
end
|
504
|
+
end
|
505
|
+
|
506
|
+
context "with no filter request parameters" do
|
507
|
+
it "destroys all notifications of the target" do
|
508
|
+
post_with_compatibility :destroy_all, target_params.merge({ typed_target_param => test_target}), valid_session
|
509
|
+
expect(test_target.notifications.count).to eq(0)
|
510
|
+
end
|
511
|
+
end
|
362
512
|
end
|
363
513
|
end
|
364
514
|
|
@@ -299,6 +299,13 @@ describe ActivityNotification::ViewHelpers, type: :helper do
|
|
299
299
|
end
|
300
300
|
end
|
301
301
|
|
302
|
+
describe '#destroy_all_notifications_path_for' do
|
303
|
+
it "returns path for the notification target" do
|
304
|
+
expect(destroy_all_notifications_path_for(target_user))
|
305
|
+
.to eq(destroy_all_user_notifications_path(target_user))
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
302
309
|
describe '#notifications_url_for' do
|
303
310
|
it "returns url for the notification target" do
|
304
311
|
expect(notifications_url_for(target_user))
|
@@ -334,6 +341,13 @@ describe ActivityNotification::ViewHelpers, type: :helper do
|
|
334
341
|
end
|
335
342
|
end
|
336
343
|
|
344
|
+
describe '#destroy_all_notifications_url_for' do
|
345
|
+
it "returns url for the notification target" do
|
346
|
+
expect(destroy_all_notifications_url_for(target_user))
|
347
|
+
.to eq(destroy_all_user_notifications_url(target_user))
|
348
|
+
end
|
349
|
+
end
|
350
|
+
|
337
351
|
describe '#subscriptions_path_for' do
|
338
352
|
it "returns path for the subscription target" do
|
339
353
|
expect(subscriptions_path_for(target_user))
|
@@ -0,0 +1,167 @@
|
|
1
|
+
describe "Notification resilience in background jobs" do
|
2
|
+
include ActiveJob::TestHelper
|
3
|
+
|
4
|
+
let(:user) { create(:user) }
|
5
|
+
let(:article) { create(:article, user: user) }
|
6
|
+
let(:comment) { create(:comment, article: article, user: create(:user)) }
|
7
|
+
|
8
|
+
before do
|
9
|
+
ActivityNotification::Mailer.deliveries.clear
|
10
|
+
clear_enqueued_jobs
|
11
|
+
clear_performed_jobs
|
12
|
+
@original_email_enabled = ActivityNotification.config.email_enabled
|
13
|
+
ActivityNotification.config.email_enabled = true
|
14
|
+
end
|
15
|
+
|
16
|
+
after do
|
17
|
+
ActivityNotification.config.email_enabled = @original_email_enabled
|
18
|
+
end
|
19
|
+
|
20
|
+
describe "Job resilience" do
|
21
|
+
it "handles missing notifications gracefully in background jobs" do
|
22
|
+
# Create a notification and destroy it to simulate race condition
|
23
|
+
notification = ActivityNotification::Notification.create!(
|
24
|
+
target: user,
|
25
|
+
notifiable: comment,
|
26
|
+
key: 'comment.create'
|
27
|
+
)
|
28
|
+
|
29
|
+
notification_id = notification.id
|
30
|
+
notification.destroy
|
31
|
+
|
32
|
+
# Expect warning to be logged
|
33
|
+
expect(Rails.logger).to receive(:warn).with(/ActivityNotification: Notification.*not found for email delivery/)
|
34
|
+
|
35
|
+
# Execute job - should not raise error
|
36
|
+
expect {
|
37
|
+
perform_enqueued_jobs do
|
38
|
+
# Simulate job trying to send email for destroyed notification
|
39
|
+
begin
|
40
|
+
destroyed_notification = ActivityNotification::Notification.find(notification_id)
|
41
|
+
destroyed_notification.send_notification_email
|
42
|
+
rescue => e
|
43
|
+
# Handle any ORM-specific "record not found" exception
|
44
|
+
if ActivityNotification::NotificationResilience.record_not_found_exception?(e)
|
45
|
+
Rails.logger.warn("ActivityNotification: Notification with id #{notification_id} not found for email delivery (#{ActivityNotification.config.orm}/#{e.class.name}), likely destroyed before job execution")
|
46
|
+
else
|
47
|
+
raise e
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
}.not_to raise_error
|
52
|
+
|
53
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(0)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
describe "Mailer job resilience" do
|
58
|
+
context "when notification is destroyed before mailer job executes" do
|
59
|
+
it "handles the scenario gracefully" do
|
60
|
+
# Create a notification
|
61
|
+
notification = ActivityNotification::Notification.create!(
|
62
|
+
target: user,
|
63
|
+
notifiable: comment,
|
64
|
+
key: 'comment.create'
|
65
|
+
)
|
66
|
+
|
67
|
+
notification_id = notification.id
|
68
|
+
|
69
|
+
# Expect warning to be logged when notification is not found
|
70
|
+
expect(Rails.logger).to receive(:warn).with(/ActivityNotification: Notification.*not found for email delivery/)
|
71
|
+
|
72
|
+
# Destroy the notification
|
73
|
+
notification.destroy
|
74
|
+
|
75
|
+
# Try to send email using the mailer directly - this should use our resilient implementation
|
76
|
+
expect {
|
77
|
+
perform_enqueued_jobs do
|
78
|
+
# Create a mock notification that will raise RecordNotFound when accessed
|
79
|
+
mock_notification = double("notification")
|
80
|
+
allow(mock_notification).to receive(:id).and_return(notification_id)
|
81
|
+
allow(mock_notification).to receive(:target).and_raise(ActiveRecord::RecordNotFound)
|
82
|
+
|
83
|
+
ActivityNotification::Mailer.send_notification_email(mock_notification).deliver_now
|
84
|
+
end
|
85
|
+
}.not_to raise_error
|
86
|
+
|
87
|
+
# No email should be sent
|
88
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(0)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
context "when notification exists during mailer job execution" do
|
93
|
+
it "sends email normally" do
|
94
|
+
# Enable email for this test
|
95
|
+
allow_any_instance_of(User).to receive(:notification_email_allowed?).and_return(true)
|
96
|
+
allow_any_instance_of(Comment).to receive(:notification_email_allowed?).and_return(true)
|
97
|
+
allow_any_instance_of(ActivityNotification::Notification).to receive(:email_subscribed?).and_return(true)
|
98
|
+
|
99
|
+
# Create a notification
|
100
|
+
notification = ActivityNotification::Notification.create!(
|
101
|
+
target: user,
|
102
|
+
notifiable: comment,
|
103
|
+
key: 'comment.create'
|
104
|
+
)
|
105
|
+
|
106
|
+
# Don't expect any warnings
|
107
|
+
expect(Rails.logger).not_to receive(:warn)
|
108
|
+
|
109
|
+
# Send email - this should work normally
|
110
|
+
expect {
|
111
|
+
perform_enqueued_jobs do
|
112
|
+
ActivityNotification::Mailer.send_notification_email(notification).deliver_now
|
113
|
+
end
|
114
|
+
}.not_to raise_error
|
115
|
+
|
116
|
+
# Email should be sent
|
117
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(1)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
describe "Multiple job resilience" do
|
123
|
+
it "continues processing other jobs even when some notifications are missing" do
|
124
|
+
# Enable email for this test
|
125
|
+
allow_any_instance_of(User).to receive(:notification_email_allowed?).and_return(true)
|
126
|
+
allow_any_instance_of(Comment).to receive(:notification_email_allowed?).and_return(true)
|
127
|
+
allow_any_instance_of(ActivityNotification::Notification).to receive(:email_subscribed?).and_return(true)
|
128
|
+
|
129
|
+
# Create two notifications
|
130
|
+
notification1 = ActivityNotification::Notification.create!(
|
131
|
+
target: user,
|
132
|
+
notifiable: comment,
|
133
|
+
key: 'comment.create'
|
134
|
+
)
|
135
|
+
|
136
|
+
notification2 = ActivityNotification::Notification.create!(
|
137
|
+
target: user,
|
138
|
+
notifiable: create(:comment, article: article, user: create(:user)),
|
139
|
+
key: 'comment.create'
|
140
|
+
)
|
141
|
+
|
142
|
+
# Destroy the first notification
|
143
|
+
notification1_id = notification1.id
|
144
|
+
notification1.destroy
|
145
|
+
|
146
|
+
# Expect one warning for the destroyed notification
|
147
|
+
expect(Rails.logger).to receive(:warn).with(/ActivityNotification: Notification.*not found for email delivery/).once
|
148
|
+
|
149
|
+
# Process both jobs
|
150
|
+
expect {
|
151
|
+
perform_enqueued_jobs do
|
152
|
+
# First job - should handle missing notification gracefully
|
153
|
+
mock_notification1 = double("notification")
|
154
|
+
allow(mock_notification1).to receive(:id).and_return(notification1_id)
|
155
|
+
allow(mock_notification1).to receive(:target).and_raise(ActiveRecord::RecordNotFound)
|
156
|
+
ActivityNotification::Mailer.send_notification_email(mock_notification1).deliver_now
|
157
|
+
|
158
|
+
# Second job - should work normally
|
159
|
+
ActivityNotification::Mailer.send_notification_email(notification2).deliver_now
|
160
|
+
end
|
161
|
+
}.not_to raise_error
|
162
|
+
|
163
|
+
# Only one email should be sent (for notification2)
|
164
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(1)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,263 @@
|
|
1
|
+
describe ActivityNotification::NotificationResilience do
|
2
|
+
include ActiveJob::TestHelper
|
3
|
+
let(:notification) { create(:notification) }
|
4
|
+
let(:test_target) { notification.target }
|
5
|
+
let(:notifications) { [create(:notification, target: test_target), create(:notification, target: test_target)] }
|
6
|
+
let(:batch_key) { 'test_batch_key' }
|
7
|
+
|
8
|
+
before do
|
9
|
+
ActivityNotification::Mailer.deliveries.clear
|
10
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(0)
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "ORM exception handling" do
|
14
|
+
describe ".current_orm" do
|
15
|
+
it "returns the configured ORM" do
|
16
|
+
expect(ActivityNotification::NotificationResilience.current_orm).to eq(ActivityNotification.config.orm)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe ".record_not_found_exception_class" do
|
21
|
+
context "with ActiveRecord ORM" do
|
22
|
+
before { allow(ActivityNotification.config).to receive(:orm).and_return(:active_record) }
|
23
|
+
|
24
|
+
it "returns ActiveRecord::RecordNotFound" do
|
25
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception_class).to eq(ActiveRecord::RecordNotFound)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
context "with Mongoid ORM" do
|
30
|
+
before { allow(ActivityNotification.config).to receive(:orm).and_return(:mongoid) }
|
31
|
+
|
32
|
+
it "returns Mongoid exception class if available" do
|
33
|
+
if defined?(Mongoid::Errors::DocumentNotFound)
|
34
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception_class).to eq(Mongoid::Errors::DocumentNotFound)
|
35
|
+
else
|
36
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception_class).to be_nil
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
context "with Dynamoid ORM" do
|
42
|
+
before { allow(ActivityNotification.config).to receive(:orm).and_return(:dynamoid) }
|
43
|
+
|
44
|
+
it "returns Dynamoid exception class if available" do
|
45
|
+
if defined?(Dynamoid::Errors::RecordNotFound)
|
46
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception_class).to eq(Dynamoid::Errors::RecordNotFound)
|
47
|
+
else
|
48
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception_class).to be_nil
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
context "with unavailable ORM exception class" do
|
54
|
+
around do |example|
|
55
|
+
# Temporarily modify the ORM_EXCEPTIONS constant
|
56
|
+
original_exceptions = ActivityNotification::NotificationResilience::ORM_EXCEPTIONS
|
57
|
+
ActivityNotification::NotificationResilience.send(:remove_const, :ORM_EXCEPTIONS)
|
58
|
+
ActivityNotification::NotificationResilience.const_set(:ORM_EXCEPTIONS, {
|
59
|
+
active_record: 'NonExistent::ExceptionClass',
|
60
|
+
mongoid: 'Mongoid::Errors::DocumentNotFound',
|
61
|
+
dynamoid: 'Dynamoid::Errors::RecordNotFound'
|
62
|
+
})
|
63
|
+
|
64
|
+
example.run
|
65
|
+
|
66
|
+
# Restore original constant
|
67
|
+
ActivityNotification::NotificationResilience.send(:remove_const, :ORM_EXCEPTIONS)
|
68
|
+
ActivityNotification::NotificationResilience.const_set(:ORM_EXCEPTIONS, original_exceptions)
|
69
|
+
end
|
70
|
+
|
71
|
+
before { allow(ActivityNotification.config).to receive(:orm).and_return(:active_record) }
|
72
|
+
|
73
|
+
it "returns nil when exception class is not available" do
|
74
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception_class).to be_nil
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
describe ".record_not_found_exception?" do
|
80
|
+
it "returns true for ActiveRecord::RecordNotFound" do
|
81
|
+
exception = ActiveRecord::RecordNotFound.new("Test error")
|
82
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception?(exception)).to be_truthy
|
83
|
+
end
|
84
|
+
|
85
|
+
it "returns false for other exceptions" do
|
86
|
+
exception = StandardError.new("Test error")
|
87
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception?(exception)).to be_falsy
|
88
|
+
end
|
89
|
+
|
90
|
+
context "when exception class constantize raises NameError" do
|
91
|
+
around do |example|
|
92
|
+
# Temporarily modify the ORM_EXCEPTIONS constant
|
93
|
+
original_exceptions = ActivityNotification::NotificationResilience::ORM_EXCEPTIONS
|
94
|
+
ActivityNotification::NotificationResilience.send(:remove_const, :ORM_EXCEPTIONS)
|
95
|
+
ActivityNotification::NotificationResilience.const_set(:ORM_EXCEPTIONS, {
|
96
|
+
active_record: 'NonExistent::ExceptionClass1',
|
97
|
+
mongoid: 'NonExistent::ExceptionClass2',
|
98
|
+
dynamoid: 'NonExistent::ExceptionClass3'
|
99
|
+
})
|
100
|
+
|
101
|
+
example.run
|
102
|
+
|
103
|
+
# Restore original constant
|
104
|
+
ActivityNotification::NotificationResilience.send(:remove_const, :ORM_EXCEPTIONS)
|
105
|
+
ActivityNotification::NotificationResilience.const_set(:ORM_EXCEPTIONS, original_exceptions)
|
106
|
+
end
|
107
|
+
|
108
|
+
it "returns false when all exception classes are unavailable" do
|
109
|
+
exception = StandardError.new("Test error")
|
110
|
+
# Should return false because all exception classes will raise NameError
|
111
|
+
expect(ActivityNotification::NotificationResilience.record_not_found_exception?(exception)).to be_falsy
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
describe "Resilient email sending" do
|
118
|
+
describe "when notification is destroyed before email job executes" do
|
119
|
+
let(:destroyed_notification) { create(:notification) }
|
120
|
+
|
121
|
+
before do
|
122
|
+
destroyed_notification_id = destroyed_notification.id
|
123
|
+
destroyed_notification.destroy
|
124
|
+
|
125
|
+
# Mock the notification to simulate the scenario where the job tries to access a destroyed notification
|
126
|
+
allow(ActivityNotification::Notification).to receive(:find).with(destroyed_notification_id).and_raise(ActiveRecord::RecordNotFound)
|
127
|
+
end
|
128
|
+
|
129
|
+
context "with send_notification_email" do
|
130
|
+
it "handles missing notification gracefully and logs warning" do
|
131
|
+
expect(Rails.logger).to receive(:warn).with(/ActivityNotification: Notification.*not found for email delivery/)
|
132
|
+
|
133
|
+
# Create a mock notification that will raise RecordNotFound when accessed
|
134
|
+
mock_notification = double("notification")
|
135
|
+
allow(mock_notification).to receive(:id).and_return(999)
|
136
|
+
allow(mock_notification).to receive(:target).and_raise(ActiveRecord::RecordNotFound)
|
137
|
+
|
138
|
+
result = nil
|
139
|
+
expect {
|
140
|
+
result = ActivityNotification::Mailer.send_notification_email(mock_notification).deliver_now
|
141
|
+
}.not_to raise_error
|
142
|
+
|
143
|
+
expect(result).to be_nil
|
144
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(0)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
context "with send_batch_notification_email" do
|
149
|
+
it "handles missing notifications gracefully and logs warning" do
|
150
|
+
expect(Rails.logger).to receive(:warn).with(/ActivityNotification: Notification.*not found for email delivery/)
|
151
|
+
|
152
|
+
# Create mock notifications that will raise RecordNotFound when accessed
|
153
|
+
mock_notifications = [double("notification")]
|
154
|
+
allow(mock_notifications.first).to receive(:id).and_return(999)
|
155
|
+
allow(mock_notifications.first).to receive(:key).and_return("test.key")
|
156
|
+
allow(mock_notifications.first).to receive(:notifiable).and_raise(ActiveRecord::RecordNotFound)
|
157
|
+
|
158
|
+
result = nil
|
159
|
+
expect {
|
160
|
+
result = ActivityNotification::Mailer.send_batch_notification_email(test_target, mock_notifications, batch_key).deliver_now
|
161
|
+
}.not_to raise_error
|
162
|
+
|
163
|
+
expect(result).to be_nil
|
164
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(0)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
describe "when notification exists" do
|
170
|
+
context "with send_notification_email" do
|
171
|
+
it "sends email normally" do
|
172
|
+
expect(Rails.logger).not_to receive(:warn)
|
173
|
+
|
174
|
+
ActivityNotification::Mailer.send_notification_email(notification).deliver_now
|
175
|
+
|
176
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(1)
|
177
|
+
expect(ActivityNotification::Mailer.deliveries.last.to[0]).to eq(notification.target.email)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
context "with send_batch_notification_email" do
|
182
|
+
it "sends batch email normally" do
|
183
|
+
expect(Rails.logger).not_to receive(:warn)
|
184
|
+
|
185
|
+
ActivityNotification::Mailer.send_batch_notification_email(test_target, notifications, batch_key).deliver_now
|
186
|
+
|
187
|
+
expect(ActivityNotification::Mailer.deliveries.size).to eq(1)
|
188
|
+
expect(ActivityNotification::Mailer.deliveries.last.to[0]).to eq(test_target.email)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
describe "Class methods (when included in a class)" do
|
195
|
+
let(:test_class) { Class.new { include ActivityNotification::NotificationResilience } }
|
196
|
+
|
197
|
+
describe "class method exception handling with NameError" do
|
198
|
+
around do |example|
|
199
|
+
# Temporarily modify the ORM_EXCEPTIONS constant
|
200
|
+
original_exceptions = ActivityNotification::NotificationResilience::ORM_EXCEPTIONS
|
201
|
+
ActivityNotification::NotificationResilience.send(:remove_const, :ORM_EXCEPTIONS)
|
202
|
+
ActivityNotification::NotificationResilience.const_set(:ORM_EXCEPTIONS, {
|
203
|
+
active_record: 'NonExistent::ClassMethodException',
|
204
|
+
mongoid: 'Mongoid::Errors::DocumentNotFound',
|
205
|
+
dynamoid: 'Dynamoid::Errors::RecordNotFound'
|
206
|
+
})
|
207
|
+
|
208
|
+
example.run
|
209
|
+
|
210
|
+
# Restore original constant
|
211
|
+
ActivityNotification::NotificationResilience.send(:remove_const, :ORM_EXCEPTIONS)
|
212
|
+
ActivityNotification::NotificationResilience.const_set(:ORM_EXCEPTIONS, original_exceptions)
|
213
|
+
end
|
214
|
+
|
215
|
+
before { allow(ActivityNotification.config).to receive(:orm).and_return(:active_record) }
|
216
|
+
|
217
|
+
it "returns nil when exception class is not available (class method)" do
|
218
|
+
expect(test_class.record_not_found_exception_class).to be_nil
|
219
|
+
end
|
220
|
+
|
221
|
+
it "returns false when exception class constantize raises NameError (class method)" do
|
222
|
+
exception = StandardError.new("Test error")
|
223
|
+
expect(test_class.record_not_found_exception?(exception)).to be_falsy
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
describe "Logging behavior" do
|
229
|
+
let(:mock_notification) { double("notification", id: 123) }
|
230
|
+
let(:resilience_instance) { Class.new { include ActivityNotification::NotificationResilience }.new }
|
231
|
+
|
232
|
+
it "logs appropriate warning message with notification ID" do
|
233
|
+
exception = ActiveRecord::RecordNotFound.new("Test error")
|
234
|
+
|
235
|
+
expect(Rails.logger).to receive(:warn).with(
|
236
|
+
/ActivityNotification: Notification with id 123 not found for email delivery.*likely destroyed before job execution/
|
237
|
+
)
|
238
|
+
|
239
|
+
resilience_instance.send(:log_missing_notification, 123, exception)
|
240
|
+
end
|
241
|
+
|
242
|
+
it "logs warning message with context information" do
|
243
|
+
exception = ActiveRecord::RecordNotFound.new("Test error")
|
244
|
+
context = { target: "User", key: "comment.create" }
|
245
|
+
|
246
|
+
expect(Rails.logger).to receive(:warn).with(
|
247
|
+
/ActivityNotification: Notification with id 123 not found for email delivery.*target: User, key: comment\.create/
|
248
|
+
)
|
249
|
+
|
250
|
+
resilience_instance.send(:log_missing_notification, 123, exception, context)
|
251
|
+
end
|
252
|
+
|
253
|
+
it "logs warning message without ID when not provided" do
|
254
|
+
exception = ActiveRecord::RecordNotFound.new("Test error")
|
255
|
+
|
256
|
+
expect(Rails.logger).to receive(:warn).with(
|
257
|
+
/ActivityNotification: Notification not found for email delivery.*likely destroyed before job execution/
|
258
|
+
)
|
259
|
+
|
260
|
+
resilience_instance.send(:log_missing_notification, nil, exception)
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
@@ -90,7 +90,7 @@ describe ActivityNotification::Notification, type: :model do
|
|
90
90
|
end
|
91
91
|
|
92
92
|
describe "with validation" do
|
93
|
-
before { @notification =
|
93
|
+
before { @notification = create(:notification) }
|
94
94
|
|
95
95
|
it "is valid with target, notifiable and key" do
|
96
96
|
expect(@notification).to be_valid
|
@@ -22,7 +22,7 @@ describe ActivityNotification::Subscription, type: :model do
|
|
22
22
|
end
|
23
23
|
|
24
24
|
describe "with validation" do
|
25
|
-
before { @subscription =
|
25
|
+
before { @subscription = create(:subscription) }
|
26
26
|
|
27
27
|
it "is valid with target and key" do
|
28
28
|
expect(@subscription).to be_valid
|
@@ -34,6 +34,7 @@ module Dummy
|
|
34
34
|
if Gem::Version.new("5.2.0") <= Rails.gem_version && Rails.gem_version < Gem::Version.new("6.0.0") && ENV['AN_TEST_DB'] != 'mongodb'
|
35
35
|
config.active_record.sqlite3.represent_boolean_as_integer = true
|
36
36
|
end
|
37
|
+
config.active_support.to_time_preserves_timezone = :zone
|
37
38
|
|
38
39
|
# Configure CORS for API mode
|
39
40
|
if defined?(Rack::Cors)
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# Mongoid 9.0+ compatibility
|
2
|
+
if ENV['AN_ORM'] == 'mongoid'
|
3
|
+
# Preload helper modules before Rails initialization
|
4
|
+
Rails.application.config.before_initialize do
|
5
|
+
# Load all helper files manually to avoid Zeitwerk issues
|
6
|
+
Dir[Rails.root.join('app', 'helpers', '*.rb')].each do |helper_file|
|
7
|
+
require_dependency helper_file
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|