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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/build.yml +9 -36
  3. data/CHANGELOG.md +26 -1
  4. data/Gemfile +1 -1
  5. data/README.md +9 -1
  6. data/activity_notification.gemspec +5 -5
  7. data/ai-curated-specs/issues/172/design.md +220 -0
  8. data/ai-curated-specs/issues/172/tasks.md +326 -0
  9. data/ai-curated-specs/issues/188/design.md +227 -0
  10. data/ai-curated-specs/issues/188/requirements.md +78 -0
  11. data/ai-curated-specs/issues/188/tasks.md +203 -0
  12. data/ai-curated-specs/issues/188/upstream-contributions.md +592 -0
  13. data/ai-curated-specs/issues/50/design.md +235 -0
  14. data/ai-curated-specs/issues/50/requirements.md +49 -0
  15. data/ai-curated-specs/issues/50/tasks.md +232 -0
  16. data/app/controllers/activity_notification/notifications_api_controller.rb +22 -0
  17. data/app/controllers/activity_notification/notifications_controller.rb +27 -1
  18. data/app/mailers/activity_notification/mailer.rb +2 -2
  19. data/app/views/activity_notification/notifications/default/_index.html.erb +6 -1
  20. data/app/views/activity_notification/notifications/default/destroy_all.js.erb +6 -0
  21. data/docs/Setup.md +43 -6
  22. data/gemfiles/Gemfile.rails-7.0 +2 -0
  23. data/gemfiles/Gemfile.rails-7.2 +0 -2
  24. data/gemfiles/Gemfile.rails-8.0 +24 -0
  25. data/lib/activity_notification/apis/notification_api.rb +51 -2
  26. data/lib/activity_notification/controllers/concerns/swagger/notifications_api.rb +59 -0
  27. data/lib/activity_notification/helpers/view_helpers.rb +28 -0
  28. data/lib/activity_notification/mailers/helpers.rb +14 -7
  29. data/lib/activity_notification/models/concerns/target.rb +16 -0
  30. data/lib/activity_notification/models.rb +1 -1
  31. data/lib/activity_notification/notification_resilience.rb +115 -0
  32. data/lib/activity_notification/orm/dynamoid/extension.rb +4 -87
  33. data/lib/activity_notification/orm/dynamoid/notification.rb +19 -2
  34. data/lib/activity_notification/orm/dynamoid.rb +42 -6
  35. data/lib/activity_notification/rails/routes.rb +3 -2
  36. data/lib/activity_notification/version.rb +1 -1
  37. data/lib/activity_notification.rb +1 -0
  38. data/lib/generators/templates/controllers/notifications_api_controller.rb +5 -0
  39. data/lib/generators/templates/controllers/notifications_api_with_devise_controller.rb +5 -0
  40. data/lib/generators/templates/controllers/notifications_controller.rb +5 -0
  41. data/lib/generators/templates/controllers/notifications_with_devise_controller.rb +5 -0
  42. data/spec/concerns/apis/notification_api_spec.rb +161 -5
  43. data/spec/concerns/models/target_spec.rb +7 -0
  44. data/spec/controllers/controller_spec_utility.rb +1 -1
  45. data/spec/controllers/notifications_api_controller_shared_examples.rb +113 -0
  46. data/spec/controllers/notifications_controller_shared_examples.rb +150 -0
  47. data/spec/helpers/view_helpers_spec.rb +14 -0
  48. data/spec/jobs/notification_resilience_job_spec.rb +167 -0
  49. data/spec/mailers/notification_resilience_spec.rb +263 -0
  50. data/spec/models/notification_spec.rb +1 -1
  51. data/spec/models/subscription_spec.rb +1 -1
  52. data/spec/rails_app/app/helpers/devise_helper.rb +2 -0
  53. data/spec/rails_app/config/application.rb +1 -0
  54. data/spec/rails_app/config/initializers/zeitwerk.rb +10 -0
  55. 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 = build(: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 = build(: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
@@ -0,0 +1,2 @@
1
+ module DeviseHelper
2
+ end
@@ -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