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.
- checksums.yaml +7 -0
- data/exe/nats_pubsub +44 -0
- data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
- data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
- data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
- data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
- data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
- data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
- data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
- data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
- data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
- data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
- data/lib/nats_pubsub/active_record/publishable.rb +192 -0
- data/lib/nats_pubsub/cli.rb +105 -0
- data/lib/nats_pubsub/core/base_repository.rb +73 -0
- data/lib/nats_pubsub/core/config.rb +152 -0
- data/lib/nats_pubsub/core/config_presets.rb +139 -0
- data/lib/nats_pubsub/core/connection.rb +103 -0
- data/lib/nats_pubsub/core/constants.rb +190 -0
- data/lib/nats_pubsub/core/duration.rb +113 -0
- data/lib/nats_pubsub/core/error_action.rb +288 -0
- data/lib/nats_pubsub/core/event.rb +275 -0
- data/lib/nats_pubsub/core/health_check.rb +470 -0
- data/lib/nats_pubsub/core/logging.rb +72 -0
- data/lib/nats_pubsub/core/message_context.rb +193 -0
- data/lib/nats_pubsub/core/presets.rb +222 -0
- data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
- data/lib/nats_pubsub/core/structured_logger.rb +141 -0
- data/lib/nats_pubsub/core/subject.rb +185 -0
- data/lib/nats_pubsub/instrumentation.rb +327 -0
- data/lib/nats_pubsub/middleware/active_record.rb +18 -0
- data/lib/nats_pubsub/middleware/chain.rb +92 -0
- data/lib/nats_pubsub/middleware/logging.rb +48 -0
- data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
- data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
- data/lib/nats_pubsub/models/event_model.rb +73 -0
- data/lib/nats_pubsub/models/inbox_event.rb +109 -0
- data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
- data/lib/nats_pubsub/models/model_utils.rb +57 -0
- data/lib/nats_pubsub/models/outbox_event.rb +113 -0
- data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
- data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
- data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
- data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
- data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
- data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
- data/lib/nats_pubsub/publisher/publisher.rb +156 -0
- data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
- data/lib/nats_pubsub/railtie.rb +52 -0
- data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
- data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
- data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
- data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
- data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
- data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
- data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
- data/lib/nats_pubsub/subscribers/pool.rb +166 -0
- data/lib/nats_pubsub/subscribers/registry.rb +114 -0
- data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
- data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
- data/lib/nats_pubsub/subscribers/worker.rb +152 -0
- data/lib/nats_pubsub/tasks/install.rake +10 -0
- data/lib/nats_pubsub/testing/helpers.rb +199 -0
- data/lib/nats_pubsub/testing/matchers.rb +208 -0
- data/lib/nats_pubsub/testing/test_harness.rb +250 -0
- data/lib/nats_pubsub/testing.rb +157 -0
- data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
- data/lib/nats_pubsub/topology/stream.rb +102 -0
- data/lib/nats_pubsub/topology/stream_support.rb +170 -0
- data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
- data/lib/nats_pubsub/topology/topology.rb +24 -0
- data/lib/nats_pubsub/version.rb +8 -0
- data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
- data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
- data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
- data/lib/nats_pubsub/web/views/layout.erb +68 -0
- data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
- data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
- data/lib/nats_pubsub/web.rb +181 -0
- data/lib/nats_pubsub.rb +290 -0
- 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
|