nats_pubsub 1.0.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 (87) hide show
  1. checksums.yaml +7 -0
  2. data/exe/nats_pubsub +44 -0
  3. data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
  4. data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
  5. data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
  6. data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
  7. data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
  8. data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
  9. data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
  10. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
  11. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
  12. data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
  13. data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
  14. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
  15. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
  16. data/lib/nats_pubsub/active_record/publishable.rb +192 -0
  17. data/lib/nats_pubsub/cli.rb +105 -0
  18. data/lib/nats_pubsub/core/base_repository.rb +73 -0
  19. data/lib/nats_pubsub/core/config.rb +152 -0
  20. data/lib/nats_pubsub/core/config_presets.rb +139 -0
  21. data/lib/nats_pubsub/core/connection.rb +103 -0
  22. data/lib/nats_pubsub/core/constants.rb +190 -0
  23. data/lib/nats_pubsub/core/duration.rb +113 -0
  24. data/lib/nats_pubsub/core/error_action.rb +288 -0
  25. data/lib/nats_pubsub/core/event.rb +275 -0
  26. data/lib/nats_pubsub/core/health_check.rb +470 -0
  27. data/lib/nats_pubsub/core/logging.rb +72 -0
  28. data/lib/nats_pubsub/core/message_context.rb +193 -0
  29. data/lib/nats_pubsub/core/presets.rb +222 -0
  30. data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
  31. data/lib/nats_pubsub/core/structured_logger.rb +141 -0
  32. data/lib/nats_pubsub/core/subject.rb +185 -0
  33. data/lib/nats_pubsub/instrumentation.rb +327 -0
  34. data/lib/nats_pubsub/middleware/active_record.rb +18 -0
  35. data/lib/nats_pubsub/middleware/chain.rb +92 -0
  36. data/lib/nats_pubsub/middleware/logging.rb +48 -0
  37. data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
  38. data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
  39. data/lib/nats_pubsub/models/event_model.rb +73 -0
  40. data/lib/nats_pubsub/models/inbox_event.rb +109 -0
  41. data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
  42. data/lib/nats_pubsub/models/model_utils.rb +57 -0
  43. data/lib/nats_pubsub/models/outbox_event.rb +113 -0
  44. data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
  45. data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
  46. data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
  47. data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
  48. data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
  49. data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
  50. data/lib/nats_pubsub/publisher/publisher.rb +156 -0
  51. data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
  52. data/lib/nats_pubsub/railtie.rb +52 -0
  53. data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
  54. data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
  55. data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
  56. data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
  57. data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
  58. data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
  59. data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
  60. data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
  61. data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
  62. data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
  63. data/lib/nats_pubsub/subscribers/pool.rb +166 -0
  64. data/lib/nats_pubsub/subscribers/registry.rb +114 -0
  65. data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
  66. data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
  67. data/lib/nats_pubsub/subscribers/worker.rb +152 -0
  68. data/lib/nats_pubsub/tasks/install.rake +10 -0
  69. data/lib/nats_pubsub/testing/helpers.rb +199 -0
  70. data/lib/nats_pubsub/testing/matchers.rb +208 -0
  71. data/lib/nats_pubsub/testing/test_harness.rb +250 -0
  72. data/lib/nats_pubsub/testing.rb +157 -0
  73. data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
  74. data/lib/nats_pubsub/topology/stream.rb +102 -0
  75. data/lib/nats_pubsub/topology/stream_support.rb +170 -0
  76. data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
  77. data/lib/nats_pubsub/topology/topology.rb +24 -0
  78. data/lib/nats_pubsub/version.rb +8 -0
  79. data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
  80. data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
  81. data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
  82. data/lib/nats_pubsub/web/views/layout.erb +68 -0
  83. data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
  84. data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
  85. data/lib/nats_pubsub/web.rb +181 -0
  86. data/lib/nats_pubsub.rb +290 -0
  87. metadata +225 -0
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Testing
5
+ # Helper methods for RSpec tests
6
+ #
7
+ # Include this module in your RSpec configuration:
8
+ #
9
+ # @example
10
+ # RSpec.configure do |config|
11
+ # config.include NatsPubsub::Testing::Helpers
12
+ # end
13
+ module Helpers
14
+ # Setup fake mode for testing (records events without processing)
15
+ #
16
+ # @example
17
+ # before do
18
+ # setup_nats_fake
19
+ # end
20
+ def setup_nats_fake
21
+ NatsPubsub::Testing.fake!
22
+ end
23
+
24
+ # Setup inline mode for testing (executes subscribers immediately)
25
+ #
26
+ # @example
27
+ # before do
28
+ # setup_nats_inline
29
+ # end
30
+ def setup_nats_inline
31
+ NatsPubsub::Testing.inline!
32
+ end
33
+
34
+ # Clear published events
35
+ #
36
+ # @example
37
+ # after do
38
+ # clear_nats_events
39
+ # end
40
+ def clear_nats_events
41
+ NatsPubsub::Testing.clear!
42
+ end
43
+
44
+ # Get all published events
45
+ #
46
+ # @return [Array<Hash>] All published events
47
+ def nats_published_events
48
+ NatsPubsub::Testing.published_events
49
+ end
50
+
51
+ # Check if an event was published
52
+ #
53
+ # @param domain [String] Domain of the event
54
+ # @param resource [String] Resource type
55
+ # @param action [String] Action performed
56
+ # @return [Boolean] true if event was published
57
+ def nats_event_published?(domain, resource, action)
58
+ NatsPubsub::Testing.published?(domain, resource, action)
59
+ end
60
+
61
+ # Find published events matching criteria
62
+ #
63
+ # @param domain [String, nil] Optional domain filter
64
+ # @param resource [String, nil] Optional resource filter
65
+ # @param action [String, nil] Optional action filter
66
+ # @return [Array<Hash>] Matching published events
67
+ def find_nats_events(**criteria)
68
+ NatsPubsub::Testing.find_events(**criteria)
69
+ end
70
+
71
+ # Get the last published event
72
+ #
73
+ # @return [Hash, nil] Last published event or nil
74
+ def last_nats_event
75
+ NatsPubsub::Testing.last_event
76
+ end
77
+
78
+ # Get count of published events
79
+ #
80
+ # @return [Integer] Number of published events
81
+ def nats_event_count
82
+ NatsPubsub::Testing.event_count
83
+ end
84
+
85
+ # Create an outbox event for testing
86
+ #
87
+ # @param attributes [Hash] Event attributes
88
+ # @return [OutboxEvent] Created outbox event
89
+ #
90
+ # @example
91
+ # event = create_outbox_event(
92
+ # subject: 'development.app.users.user.created',
93
+ # payload: { id: 1, email: 'test@example.com' },
94
+ # status: 'pending'
95
+ # )
96
+ def create_outbox_event(**attributes)
97
+ model = NatsPubsub.config.outbox_model.constantize
98
+ model.create!(default_outbox_attributes.merge(attributes))
99
+ end
100
+
101
+ # Create an inbox event for testing
102
+ #
103
+ # @param attributes [Hash] Event attributes
104
+ # @return [InboxEvent] Created inbox event
105
+ #
106
+ # @example
107
+ # event = create_inbox_event(
108
+ # subject: 'development.app.users.user.created',
109
+ # payload: { id: 1, email: 'test@example.com' },
110
+ # status: 'received'
111
+ # )
112
+ def create_inbox_event(**attributes)
113
+ model = NatsPubsub.config.inbox_model.constantize
114
+ model.create!(default_inbox_attributes.merge(attributes))
115
+ end
116
+
117
+ # Stub NATS connection to avoid real connections in tests
118
+ #
119
+ # @example
120
+ # before do
121
+ # stub_nats_connection
122
+ # end
123
+ def stub_nats_connection
124
+ return unless defined?(RSpec)
125
+
126
+ connection = instance_double(NatsPubsub::Connection)
127
+ allow(NatsPubsub::Connection).to receive(:instance).and_return(connection)
128
+ allow(connection).to receive(:nats).and_return(double('nats', jetstream: double('jetstream')))
129
+ allow(connection).to receive(:connected?).and_return(true)
130
+ connection
131
+ end
132
+
133
+ private
134
+
135
+ def default_outbox_attributes
136
+ {
137
+ event_id: SecureRandom.uuid,
138
+ subject: 'test.subject',
139
+ payload: '{}',
140
+ status: 'pending',
141
+ attempts: 0
142
+ }
143
+ end
144
+
145
+ def default_inbox_attributes
146
+ {
147
+ event_id: SecureRandom.uuid,
148
+ subject: 'test.subject',
149
+ payload: '{}',
150
+ status: 'received',
151
+ delivery_count: 1
152
+ }
153
+ end
154
+ end
155
+
156
+ # RSpec configuration block for automatic setup
157
+ #
158
+ # Add this to your spec_helper.rb or rails_helper.rb:
159
+ #
160
+ # @example
161
+ # require 'nats_pubsub/testing/helpers'
162
+ #
163
+ # RSpec.configure do |config|
164
+ # config.include NatsPubsub::Testing::Helpers
165
+ #
166
+ # config.before(:each, nats_fake: true) do
167
+ # setup_nats_fake
168
+ # end
169
+ #
170
+ # config.before(:each, nats_inline: true) do
171
+ # setup_nats_inline
172
+ # end
173
+ #
174
+ # config.after(:each) do
175
+ # clear_nats_events if defined?(NatsPubsub::Testing)
176
+ # end
177
+ # end
178
+ module RSpecConfiguration
179
+ def self.configure(config)
180
+ config.include Helpers
181
+
182
+ # Automatically enable fake mode for tests tagged with nats_fake: true
183
+ config.before(:each, nats_fake: true) do
184
+ setup_nats_fake
185
+ end
186
+
187
+ # Automatically enable inline mode for tests tagged with nats_inline: true
188
+ config.before(:each, nats_inline: true) do
189
+ setup_nats_inline
190
+ end
191
+
192
+ # Clear events after each test
193
+ config.after(:each) do
194
+ clear_nats_events if defined?(NatsPubsub::Testing)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(RSpec)
4
+ # RSpec matchers for NatsPubsub testing
5
+
6
+ # Matcher for checking if an event was published
7
+ #
8
+ # @example
9
+ # expect { user.save }.to have_published_event('users', 'user', 'created')
10
+ RSpec::Matchers.define :have_published_event do |domain, resource, action|
11
+ match do |_actual|
12
+ NatsPubsub::Testing.published_events.any? do |event|
13
+ event[:domain] == domain &&
14
+ event[:resource] == resource &&
15
+ event[:action] == action
16
+ end
17
+ end
18
+
19
+ failure_message do
20
+ published = NatsPubsub::Testing.published_events
21
+ .map { |e| "#{e[:domain]}.#{e[:resource]}.#{e[:action]}" }
22
+ .join(', ')
23
+
24
+ if published.empty?
25
+ "expected to publish #{domain}.#{resource}.#{action}, but no events were published"
26
+ else
27
+ "expected to publish #{domain}.#{resource}.#{action}, but published: #{published}"
28
+ end
29
+ end
30
+
31
+ supports_block_expectations
32
+ end
33
+
34
+ # Matcher for checking event payload
35
+ #
36
+ # @example
37
+ # expect { user.save }.to have_published_event_with_payload('users', 'user', 'created', id: 1)
38
+ RSpec::Matchers.define :have_published_event_with_payload do |domain, resource, action, expected_payload|
39
+ match do |_actual|
40
+ NatsPubsub::Testing.published_events.any? do |event|
41
+ next false unless event[:domain] == domain &&
42
+ event[:resource] == resource &&
43
+ event[:action] == action
44
+
45
+ # Check if expected payload is a subset of actual payload
46
+ expected_payload.all? do |key, value|
47
+ event[:payload][key] == value
48
+ end
49
+ end
50
+ end
51
+
52
+ failure_message do
53
+ matching_events = NatsPubsub::Testing.published_events.select do |e|
54
+ e[:domain] == domain && e[:resource] == resource && e[:action] == action
55
+ end
56
+
57
+ if matching_events.empty?
58
+ "expected to publish #{domain}.#{resource}.#{action} with payload #{expected_payload.inspect}, " \
59
+ "but no matching events were published"
60
+ else
61
+ "expected to publish #{domain}.#{resource}.#{action} with payload #{expected_payload.inspect}, " \
62
+ "but got: #{matching_events.map { |e| e[:payload] }.inspect}"
63
+ end
64
+ end
65
+
66
+ supports_block_expectations
67
+ end
68
+
69
+ # Matcher for checking outbox event creation
70
+ #
71
+ # @example
72
+ # expect { publisher.publish('users', 'user', 'created', id: 1) }
73
+ # .to enqueue_outbox_event
74
+ #
75
+ # @example with subject matching
76
+ # expect { publisher.publish('users', 'user', 'created', id: 1) }
77
+ # .to enqueue_outbox_event.with_subject_matching(/users\.user\.created/)
78
+ RSpec::Matchers.define :enqueue_outbox_event do
79
+ chain :with_subject_matching do |pattern|
80
+ @subject_pattern = pattern
81
+ end
82
+
83
+ chain :with_status do |status|
84
+ @status = status
85
+ end
86
+
87
+ chain :with_payload_including do |expected_payload|
88
+ @expected_payload = expected_payload
89
+ end
90
+
91
+ match do |block|
92
+ @outbox_model = NatsPubsub.config.outbox_model.constantize
93
+ @before_count = @outbox_model.count
94
+
95
+ block.call
96
+
97
+ @after_count = @outbox_model.count
98
+ @new_events = @outbox_model.last(@after_count - @before_count)
99
+
100
+ return false if @new_events.empty?
101
+
102
+ @matching_event = @new_events.find do |event|
103
+ matches_criteria?(event)
104
+ end
105
+
106
+ !@matching_event.nil?
107
+ end
108
+
109
+ def matches_criteria?(event)
110
+ return false if @subject_pattern && !event.subject.match?(@subject_pattern)
111
+ return false if @status && event.status != @status
112
+
113
+ if @expected_payload
114
+ payload = parse_payload(event.payload)
115
+ return false unless payload_matches?(payload, @expected_payload)
116
+ end
117
+
118
+ true
119
+ end
120
+
121
+ def parse_payload(payload)
122
+ case payload
123
+ when String
124
+ JSON.parse(payload).deep_symbolize_keys
125
+ when Hash
126
+ payload.deep_symbolize_keys
127
+ else
128
+ payload
129
+ end
130
+ rescue JSON::ParserError
131
+ payload
132
+ end
133
+
134
+ def payload_matches?(actual, expected)
135
+ expected.all? do |key, value|
136
+ actual[key] == value
137
+ end
138
+ end
139
+
140
+ failure_message do
141
+ if @new_events.empty?
142
+ "expected to enqueue outbox event, but no new events were created"
143
+ else
144
+ msg = "expected to enqueue outbox event"
145
+ msg += " with subject matching #{@subject_pattern.inspect}" if @subject_pattern
146
+ msg += " with status #{@status.inspect}" if @status
147
+ msg += " with payload including #{@expected_payload.inspect}" if @expected_payload
148
+ msg += ", but got:\n"
149
+ @new_events.each do |event|
150
+ msg += " - subject: #{event.subject}, status: #{event.status}\n"
151
+ end
152
+ msg
153
+ end
154
+ end
155
+
156
+ supports_block_expectations
157
+ end
158
+
159
+ # Matcher for checking subscriber registration
160
+ #
161
+ # @example
162
+ # expect(UserSubscriber).to subscribe_to('development.app.users.user.>')
163
+ RSpec::Matchers.define :subscribe_to do |subject_pattern|
164
+ match do |subscriber_class|
165
+ return false unless subscriber_class.respond_to?(:subjects)
166
+
167
+ @actual_subjects = subscriber_class.subjects
168
+ @actual_subjects.any? do |subject|
169
+ subject_matches?(subject, subject_pattern)
170
+ end
171
+ end
172
+
173
+ def subject_matches?(actual, expected)
174
+ # Exact match
175
+ return true if actual == expected
176
+
177
+ # Wildcard matching
178
+ actual_parts = actual.split('.')
179
+ expected_parts = expected.split('.')
180
+
181
+ return false if expected_parts.last != '>' && actual_parts.size != expected_parts.size
182
+
183
+ expected_parts.each_with_index do |expected_part, i|
184
+ actual_part = actual_parts[i]
185
+
186
+ case expected_part
187
+ when '*'
188
+ next
189
+ when '>'
190
+ return true
191
+ else
192
+ return false if actual_part != expected_part
193
+ end
194
+ end
195
+
196
+ true
197
+ end
198
+
199
+ failure_message do
200
+ "expected #{subscriber_class} to subscribe to #{subject_pattern.inspect}, " \
201
+ "but subscribed to: #{@actual_subjects.inspect}"
202
+ end
203
+
204
+ failure_message_when_negated do
205
+ "expected #{subscriber_class} not to subscribe to #{subject_pattern.inspect}"
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Testing
5
+ # TestHarness provides comprehensive testing utilities for NatsPubsub
6
+ #
7
+ # Features:
8
+ # - Message capture and inspection
9
+ # - Inline/synchronous processing for deterministic tests
10
+ # - DLQ message tracking
11
+ # - Subscriber call tracking
12
+ # - Error simulation
13
+ #
14
+ # @example Using in RSpec
15
+ # RSpec.describe OrderProcessor do
16
+ # let(:harness) { NatsPubsub::Testing::TestHarness.new }
17
+ #
18
+ # before { harness.setup }
19
+ # after { harness.cleanup }
20
+ #
21
+ # it 'processes orders' do
22
+ # harness.publish('order.placed', { id: '123' })
23
+ # expect(harness.received('order.placed').size).to eq(1)
24
+ # expect(harness.subscriber_called?(OrderSubscriber)).to be true
25
+ # end
26
+ # end
27
+ #
28
+ class TestHarness
29
+ attr_reader :messages, :dlq_messages, :subscriber_calls, :error_simulations
30
+
31
+ # Initialize a new test harness
32
+ #
33
+ # @param subscribers [Array<Class>] Subscriber classes to register
34
+ # @param inline_mode [Boolean] Process messages synchronously
35
+ def initialize(subscribers: [], inline_mode: true)
36
+ @subscribers = subscribers
37
+ @inline_mode = inline_mode
38
+ @messages = []
39
+ @dlq_messages = []
40
+ @subscriber_calls = Hash.new(0)
41
+ @error_simulations = {}
42
+ end
43
+
44
+ # Setup the test harness
45
+ #
46
+ # @return [void]
47
+ def setup
48
+ # Enable fake mode
49
+ NatsPubsub.fake!
50
+
51
+ # Register subscribers
52
+ @subscribers.each do |subscriber_class|
53
+ register_subscriber(subscriber_class)
54
+ end
55
+ end
56
+
57
+ # Cleanup after tests
58
+ #
59
+ # @return [void]
60
+ def cleanup
61
+ clear
62
+ NatsPubsub.unfake!
63
+ end
64
+
65
+ # Register a subscriber for testing
66
+ #
67
+ # @param subscriber_class [Class] Subscriber class
68
+ # @return [void]
69
+ def register_subscriber(subscriber_class)
70
+ subscriber = subscriber_class.new
71
+
72
+ # Wrap subscriber to track calls
73
+ original_call = subscriber.method(:call) if subscriber.respond_to?(:call)
74
+ original_handle = subscriber.method(:handle) if subscriber.respond_to?(:handle)
75
+
76
+ if original_call
77
+ subscriber.define_singleton_method(:call) do |message, metadata|
78
+ TestHarness.track_call(subscriber_class, @subscriber_calls)
79
+ TestHarness.check_error_simulation(subscriber_class, @error_simulations)
80
+ original_call.call(message, metadata)
81
+ end
82
+ end
83
+
84
+ if original_handle
85
+ subscriber.define_singleton_method(:handle) do |message, context|
86
+ TestHarness.track_call(subscriber_class, @subscriber_calls)
87
+ TestHarness.check_error_simulation(subscriber_class, @error_simulations)
88
+ original_handle.call(message, context)
89
+ end
90
+ end
91
+
92
+ NatsPubsub.register_subscriber(subscriber)
93
+ end
94
+
95
+ # Publish a message for testing
96
+ #
97
+ # @param topic [String] Topic to publish to
98
+ # @param message [Hash] Message payload
99
+ # @param options [Hash] Publish options
100
+ # @return [void]
101
+ def publish(topic, message, **options)
102
+ # Capture message
103
+ @messages << {
104
+ topic: topic,
105
+ message: message,
106
+ options: options,
107
+ timestamp: Time.now
108
+ }
109
+
110
+ # Actually publish
111
+ NatsPubsub.publish(topic: topic, message: message, **options)
112
+ end
113
+
114
+ # Get all messages received on a topic
115
+ #
116
+ # @param topic [String] Topic to filter by
117
+ # @return [Array<Hash>] Array of captured messages
118
+ def received(topic)
119
+ @messages.select { |m| m[:topic] == topic }
120
+ end
121
+
122
+ # Get the last message received on a topic
123
+ #
124
+ # @param topic [String] Topic to filter by
125
+ # @return [Hash, nil] Last captured message or nil
126
+ def last_message(topic)
127
+ received(topic).last&.dig(:message)
128
+ end
129
+
130
+ # Check if a subscriber was called
131
+ #
132
+ # @param subscriber_class [Class] Subscriber class
133
+ # @return [Boolean] True if subscriber was called
134
+ def subscriber_called?(subscriber_class)
135
+ @subscriber_calls[subscriber_class].positive?
136
+ end
137
+
138
+ # Get the number of times a subscriber was called
139
+ #
140
+ # @param subscriber_class [Class] Subscriber class
141
+ # @return [Integer] Number of calls
142
+ def subscriber_call_count(subscriber_class)
143
+ @subscriber_calls[subscriber_class]
144
+ end
145
+
146
+ # Get all DLQ messages
147
+ #
148
+ # @return [Array<Hash>] Array of captured DLQ messages
149
+ def dlq_messages
150
+ @dlq_messages
151
+ end
152
+
153
+ # Get the last DLQ message
154
+ #
155
+ # @return [Hash, nil] Last DLQ message or nil
156
+ def last_dlq_message
157
+ @dlq_messages.last
158
+ end
159
+
160
+ # Simulate an error for a subscriber
161
+ #
162
+ # @param subscriber_class [Class] Subscriber class
163
+ # @param error [Exception] Error to raise
164
+ # @return [void]
165
+ def simulate_error(subscriber_class, error)
166
+ @error_simulations[subscriber_class] = error
167
+ end
168
+
169
+ # Clear simulated error for a subscriber
170
+ #
171
+ # @param subscriber_class [Class] Subscriber class
172
+ # @return [void]
173
+ def clear_simulated_error(subscriber_class)
174
+ @error_simulations.delete(subscriber_class)
175
+ end
176
+
177
+ # Clear all captured data
178
+ #
179
+ # @return [void]
180
+ def clear
181
+ @messages.clear
182
+ @dlq_messages.clear
183
+ @subscriber_calls.clear
184
+ @error_simulations.clear
185
+ end
186
+
187
+ # Wait for a condition to be true
188
+ #
189
+ # @param timeout [Float] Timeout in seconds
190
+ # @param interval [Float] Polling interval in seconds
191
+ # @yield Block that returns true when condition is met
192
+ # @return [void]
193
+ # @raise [Timeout::Error] If condition not met within timeout
194
+ def wait_for(timeout: 5.0, interval: 0.1)
195
+ start_time = Time.now
196
+
197
+ loop do
198
+ return if yield
199
+
200
+ raise Timeout::Error, "Condition not met within #{timeout}s" if Time.now - start_time > timeout
201
+
202
+ sleep interval
203
+ end
204
+ end
205
+
206
+ # Wait for a subscriber to be called
207
+ #
208
+ # @param subscriber_class [Class] Subscriber class
209
+ # @param timeout [Float] Timeout in seconds
210
+ # @return [void]
211
+ # @raise [Timeout::Error] If not called within timeout
212
+ def wait_for_subscriber(subscriber_class, timeout: 5.0)
213
+ wait_for(timeout: timeout) { subscriber_called?(subscriber_class) }
214
+ end
215
+
216
+ # Wait for messages on a topic
217
+ #
218
+ # @param topic [String] Topic to wait for
219
+ # @param count [Integer] Expected number of messages
220
+ # @param timeout [Float] Timeout in seconds
221
+ # @return [void]
222
+ # @raise [Timeout::Error] If messages not received within timeout
223
+ def wait_for_messages(topic, count: 1, timeout: 5.0)
224
+ wait_for(timeout: timeout) { received(topic).size >= count }
225
+ end
226
+
227
+ # Track a subscriber call
228
+ #
229
+ # @param subscriber_class [Class] Subscriber class
230
+ # @param calls_hash [Hash] Subscriber calls hash
231
+ # @return [void]
232
+ # @api private
233
+ def self.track_call(subscriber_class, calls_hash)
234
+ calls_hash[subscriber_class] += 1
235
+ end
236
+
237
+ # Check for error simulation
238
+ #
239
+ # @param subscriber_class [Class] Subscriber class
240
+ # @param simulations_hash [Hash] Error simulations hash
241
+ # @return [void]
242
+ # @raise [Exception] If error is simulated
243
+ # @api private
244
+ def self.check_error_simulation(subscriber_class, simulations_hash)
245
+ error = simulations_hash[subscriber_class]
246
+ raise error if error
247
+ end
248
+ end
249
+ end
250
+ end