jetstream_bridge 4.0.4 → 4.2.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/CHANGELOG.md +106 -0
- data/README.md +22 -1402
- data/docs/GETTING_STARTED.md +92 -0
- data/docs/PRODUCTION.md +503 -0
- data/docs/TESTING.md +414 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +101 -5
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +17 -3
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +19 -7
- data/lib/jetstream_bridge/consumer/message_processor.rb +88 -52
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +24 -15
- data/lib/jetstream_bridge/core/bridge_helpers.rb +85 -0
- data/lib/jetstream_bridge/core/config.rb +27 -4
- data/lib/jetstream_bridge/core/connection.rb +162 -13
- data/lib/jetstream_bridge/core.rb +8 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +13 -7
- data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
- data/lib/jetstream_bridge/publisher/publisher.rb +10 -5
- data/lib/jetstream_bridge/rails/integration.rb +153 -0
- data/lib/jetstream_bridge/rails/railtie.rb +53 -0
- data/lib/jetstream_bridge/rails.rb +5 -0
- data/lib/jetstream_bridge/tasks/install.rake +1 -1
- data/lib/jetstream_bridge/test_helpers/fixtures.rb +41 -0
- data/lib/jetstream_bridge/test_helpers/integration_helpers.rb +77 -0
- data/lib/jetstream_bridge/test_helpers/matchers.rb +98 -0
- data/lib/jetstream_bridge/test_helpers/mock_nats.rb +524 -0
- data/lib/jetstream_bridge/test_helpers.rb +85 -121
- data/lib/jetstream_bridge/topology/overlap_guard.rb +46 -0
- data/lib/jetstream_bridge/topology/stream.rb +7 -4
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +138 -63
- metadata +32 -12
- data/lib/jetstream_bridge/railtie.rb +0 -49
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# Integration helpers to exercise the mock NATS storage end-to-end.
|
|
6
|
+
module IntegrationHelpers
|
|
7
|
+
# Publish an event and wait for it to appear in mock storage
|
|
8
|
+
#
|
|
9
|
+
# @param event_attrs [Hash] Event attributes to publish
|
|
10
|
+
# @param timeout [Integer] Maximum seconds to wait
|
|
11
|
+
# @return [Models::PublishResult] Publish result
|
|
12
|
+
# @raise [Timeout::Error] If event doesn't appear within timeout
|
|
13
|
+
def publish_and_wait(timeout: 1, **event_attrs)
|
|
14
|
+
result = JetstreamBridge.publish(**event_attrs)
|
|
15
|
+
|
|
16
|
+
deadline = Time.now + timeout
|
|
17
|
+
until Time.now > deadline
|
|
18
|
+
storage = JetstreamBridge::TestHelpers.mock_storage
|
|
19
|
+
break if storage.messages.any? { |m| m[:header]['nats-msg-id'] == result.event_id }
|
|
20
|
+
|
|
21
|
+
sleep 0.01
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
result
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Consume events from mock storage
|
|
28
|
+
#
|
|
29
|
+
# @param batch_size [Integer] Number of events to consume
|
|
30
|
+
# @yield [event] Block to handle each event
|
|
31
|
+
# @return [Array<Hash>] Consumed events
|
|
32
|
+
def consume_events(batch_size: 10, &handler)
|
|
33
|
+
storage = JetstreamBridge::TestHelpers.mock_storage
|
|
34
|
+
messages = storage.messages.first(batch_size)
|
|
35
|
+
|
|
36
|
+
messages.each do |msg|
|
|
37
|
+
event = JetstreamBridge::Models::Event.from_nats_message(
|
|
38
|
+
OpenStruct.new(
|
|
39
|
+
subject: msg[:subject],
|
|
40
|
+
data: msg[:data],
|
|
41
|
+
header: msg[:header],
|
|
42
|
+
metadata: OpenStruct.new(
|
|
43
|
+
sequence: OpenStruct.new(stream: msg[:sequence]),
|
|
44
|
+
num_delivered: msg[:delivery_count],
|
|
45
|
+
stream: 'test-stream',
|
|
46
|
+
consumer: 'test-consumer'
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
handler&.call(event)
|
|
52
|
+
JetstreamBridge::TestHelpers.record_consumed_event(event.to_h)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
JetstreamBridge::TestHelpers.consumed_events
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Wait for a specific number of messages in mock storage
|
|
59
|
+
#
|
|
60
|
+
# @param count [Integer] Expected message count
|
|
61
|
+
# @param timeout [Integer] Maximum seconds to wait
|
|
62
|
+
# @return [Boolean] true if count reached, false if timeout
|
|
63
|
+
def wait_for_messages(count, timeout: 2)
|
|
64
|
+
deadline = Time.now + timeout
|
|
65
|
+
storage = JetstreamBridge::TestHelpers.mock_storage
|
|
66
|
+
|
|
67
|
+
until Time.now > deadline
|
|
68
|
+
return true if storage.messages.size >= count
|
|
69
|
+
|
|
70
|
+
sleep 0.01
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# RSpec matchers for asserting publish outcomes and captured events.
|
|
6
|
+
module Matchers
|
|
7
|
+
# Matcher for checking if an event was published
|
|
8
|
+
#
|
|
9
|
+
# @param event_type [String] Event type to match
|
|
10
|
+
# @param payload [Hash] Optional payload attributes to match
|
|
11
|
+
# @return [HavePublished] Matcher instance
|
|
12
|
+
def have_published(event_type:, payload: {})
|
|
13
|
+
HavePublished.new(event_type, payload)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Matcher implementation for have_published
|
|
17
|
+
class HavePublished
|
|
18
|
+
def initialize(event_type, payload_attributes)
|
|
19
|
+
@event_type = event_type
|
|
20
|
+
@payload_attributes = payload_attributes
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def matches?(_actual)
|
|
24
|
+
TestHelpers.published_events.any? do |event|
|
|
25
|
+
matches_event_type?(event) && matches_payload?(event)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def failure_message
|
|
30
|
+
"expected to have published event_type: #{@event_type.inspect} " \
|
|
31
|
+
"with payload: #{@payload_attributes.inspect}\n" \
|
|
32
|
+
"but found events: #{TestHelpers.published_events.map { |e| e['event_type'] }.inspect}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def failure_message_when_negated
|
|
36
|
+
"expected not to have published event_type: #{@event_type.inspect} " \
|
|
37
|
+
"with payload: #{@payload_attributes.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def matches_event_type?(event)
|
|
43
|
+
event['event_type'] == @event_type || event[:event_type] == @event_type
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def matches_payload?(event)
|
|
47
|
+
payload = event['payload'] || event[:payload] || {}
|
|
48
|
+
@payload_attributes.all? do |key, value|
|
|
49
|
+
payload_value = payload[key.to_s] || payload[key.to_sym]
|
|
50
|
+
if value.is_a?(RSpec::Matchers::BuiltIn::BaseMatcher)
|
|
51
|
+
value.matches?(payload)
|
|
52
|
+
else
|
|
53
|
+
payload_value == value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Matcher for checking publish result success
|
|
60
|
+
def be_publish_success
|
|
61
|
+
BePublishSuccess.new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class BePublishSuccess
|
|
65
|
+
def matches?(actual)
|
|
66
|
+
actual.respond_to?(:success?) && actual.success?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def failure_message
|
|
70
|
+
'expected PublishResult to be successful but it failed'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def failure_message_when_negated
|
|
74
|
+
'expected PublishResult to not be successful but it was'
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Matcher for checking publish result failure
|
|
79
|
+
def be_publish_failure
|
|
80
|
+
BePublishFailure.new
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class BePublishFailure
|
|
84
|
+
def matches?(actual)
|
|
85
|
+
actual.respond_to?(:failure?) && actual.failure?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def failure_message
|
|
89
|
+
'expected PublishResult to be a failure but it succeeded'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def failure_message_when_negated
|
|
93
|
+
'expected PublishResult to not be a failure but it was'
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module TestHelpers
|
|
5
|
+
# In-memory mock for NATS JetStream connection
|
|
6
|
+
# Simulates the NATS::IO::Client and JetStream API without requiring a real server
|
|
7
|
+
module MockNats
|
|
8
|
+
class MockConnection
|
|
9
|
+
attr_reader :connected_at, :callbacks
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@connected = false
|
|
13
|
+
@connected_at = nil
|
|
14
|
+
@callbacks = { reconnect: [], disconnect: [], error: [] }
|
|
15
|
+
# Use global storage to ensure persistence across test helper calls
|
|
16
|
+
@jetstream = MockJetStream.new(MockNats.storage)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def connect(_urls = nil, **_options)
|
|
20
|
+
@connected = true
|
|
21
|
+
@connected_at = Time.now
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def jetstream
|
|
26
|
+
raise NATS::IO::NoRespondersError, 'JetStream not available' unless @connected
|
|
27
|
+
|
|
28
|
+
@jetstream
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def connected?
|
|
32
|
+
@connected
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on_reconnect(&block)
|
|
36
|
+
@callbacks[:reconnect] << block if block
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_disconnect(&block)
|
|
40
|
+
@callbacks[:disconnect] << block if block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def on_error(&block)
|
|
44
|
+
@callbacks[:error] << block if block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def rtt
|
|
48
|
+
0.001 # 1ms simulated round-trip time
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Simulate JetStream API requests
|
|
52
|
+
# Used by overlap_guard.rb for stream management operations
|
|
53
|
+
def request(subject, _payload, timeout: 1)
|
|
54
|
+
raise NATS::IO::NoRespondersError, 'Not connected' unless @connected
|
|
55
|
+
|
|
56
|
+
# Parse the API request
|
|
57
|
+
response_data = case subject
|
|
58
|
+
when '$JS.API.STREAM.NAMES'
|
|
59
|
+
# Return list of stream names
|
|
60
|
+
stream_names = @jetstream.storage.streams.keys.map { |name| { 'name' => name } }
|
|
61
|
+
{
|
|
62
|
+
'type' => 'io.nats.jetstream.api.v1.stream_names_response',
|
|
63
|
+
'total' => stream_names.size,
|
|
64
|
+
'offset' => 0,
|
|
65
|
+
'limit' => 1024,
|
|
66
|
+
'streams' => stream_names
|
|
67
|
+
}
|
|
68
|
+
when /^\$JS\.API\.STREAM\.INFO\.(.+)$/
|
|
69
|
+
# Return stream info for specific stream
|
|
70
|
+
stream_name = ::Regexp.last_match(1)
|
|
71
|
+
stream = @jetstream.storage.find_stream(stream_name)
|
|
72
|
+
if stream
|
|
73
|
+
info = stream.info
|
|
74
|
+
{
|
|
75
|
+
'type' => 'io.nats.jetstream.api.v1.stream_info_response',
|
|
76
|
+
'config' => {
|
|
77
|
+
'name' => info.config.name,
|
|
78
|
+
'subjects' => info.config.subjects
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
{
|
|
83
|
+
'error' => {
|
|
84
|
+
'code' => 404,
|
|
85
|
+
'description' => 'stream not found'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
# Generic response for unknown API calls
|
|
91
|
+
{ 'type' => 'io.nats.jetstream.api.v1.response' }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Return a mock message object with the response data
|
|
95
|
+
MockApiResponse.new(Oj.dump(response_data, mode: :compat))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def close
|
|
99
|
+
@connected = false
|
|
100
|
+
@callbacks[:disconnect].each(&:call)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Test helpers for simulating connection events
|
|
104
|
+
def simulate_disconnect!
|
|
105
|
+
@connected = false
|
|
106
|
+
@callbacks[:disconnect].each(&:call)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def simulate_reconnect!
|
|
110
|
+
@connected = true
|
|
111
|
+
@callbacks[:reconnect].each(&:call)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def simulate_error!(error)
|
|
115
|
+
@callbacks[:error].each { |cb| cb.call(error) }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class MockJetStream
|
|
120
|
+
attr_reader :storage
|
|
121
|
+
|
|
122
|
+
def initialize(storage = nil)
|
|
123
|
+
@storage = storage || MockNats.storage
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def account_info
|
|
127
|
+
OpenStruct.new(
|
|
128
|
+
memory: 1024 * 1024 * 100,
|
|
129
|
+
storage: 1024 * 1024 * 1000,
|
|
130
|
+
streams: @storage.streams.count,
|
|
131
|
+
consumers: @storage.consumers.count
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def publish(subject, data, header: {})
|
|
136
|
+
@storage.publish(subject, data, header)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pull_subscribe(subject, durable_name, **options)
|
|
140
|
+
@storage.create_subscription(subject, durable_name, options)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def consumer_info(stream_name, durable_name)
|
|
144
|
+
consumer = @storage.find_consumer(stream_name, durable_name)
|
|
145
|
+
raise NATS::JetStream::Error, 'consumer not found' unless consumer
|
|
146
|
+
|
|
147
|
+
consumer.info
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def stream_info(stream_name)
|
|
151
|
+
stream = @storage.find_stream(stream_name)
|
|
152
|
+
raise NATS::JetStream::Error, 'stream not found' unless stream
|
|
153
|
+
|
|
154
|
+
stream.info
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def add_stream(config)
|
|
158
|
+
@storage.add_stream(config)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def delete_stream(name)
|
|
162
|
+
@storage.delete_stream(name)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def add_consumer(stream_name, **config)
|
|
166
|
+
@storage.add_consumer(stream_name, config)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def delete_consumer(stream_name, consumer_name)
|
|
170
|
+
@storage.delete_consumer(stream_name, consumer_name)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
class InMemoryStorage
|
|
175
|
+
attr_reader :streams, :consumers, :messages, :subscriptions
|
|
176
|
+
|
|
177
|
+
def initialize
|
|
178
|
+
@streams = {}
|
|
179
|
+
@consumers = {}
|
|
180
|
+
@messages = []
|
|
181
|
+
@subscriptions = {}
|
|
182
|
+
@sequence_counter = 0
|
|
183
|
+
@mutex = Mutex.new
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def publish(subject, data, header)
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
event_id = header['nats-msg-id'] || SecureRandom.uuid
|
|
189
|
+
|
|
190
|
+
# Check for duplicate
|
|
191
|
+
duplicate = @messages.any? { |msg| msg[:header]['nats-msg-id'] == event_id }
|
|
192
|
+
|
|
193
|
+
unless duplicate
|
|
194
|
+
@sequence_counter += 1
|
|
195
|
+
@messages << {
|
|
196
|
+
subject: subject,
|
|
197
|
+
data: data,
|
|
198
|
+
header: header,
|
|
199
|
+
sequence: @sequence_counter,
|
|
200
|
+
timestamp: Time.now,
|
|
201
|
+
delivery_count: 0
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
MockAck.new(
|
|
206
|
+
duplicate: duplicate,
|
|
207
|
+
sequence: @sequence_counter,
|
|
208
|
+
stream: find_stream_for_subject(subject)&.name || 'mock-stream'
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def create_subscription(subject, durable_name, options)
|
|
214
|
+
@mutex.synchronize do
|
|
215
|
+
stream_name = options[:stream] || find_stream_for_subject(subject)&.name || 'mock-stream'
|
|
216
|
+
|
|
217
|
+
subscription = MockSubscription.new(
|
|
218
|
+
subject: subject,
|
|
219
|
+
durable_name: durable_name,
|
|
220
|
+
storage: self,
|
|
221
|
+
stream_name: stream_name,
|
|
222
|
+
options: options
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@subscriptions[durable_name] = subscription
|
|
226
|
+
|
|
227
|
+
# Register consumer
|
|
228
|
+
@consumers[durable_name] = MockConsumer.new(
|
|
229
|
+
name: durable_name,
|
|
230
|
+
stream: stream_name,
|
|
231
|
+
config: options
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
subscription
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def fetch_messages(subject, durable_name, batch_size, _timeout)
|
|
239
|
+
@mutex.synchronize do
|
|
240
|
+
consumer = @consumers[durable_name]
|
|
241
|
+
stream_name = consumer&.stream || 'mock-stream'
|
|
242
|
+
|
|
243
|
+
# Find messages matching the subject
|
|
244
|
+
matching = @messages.select do |msg|
|
|
245
|
+
msg[:subject] == subject && msg[:delivery_count] < max_deliver_for(durable_name)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Take up to batch_size messages
|
|
249
|
+
to_deliver = matching.first(batch_size)
|
|
250
|
+
|
|
251
|
+
# Increment delivery count and return MockMessage objects
|
|
252
|
+
to_deliver.map do |msg|
|
|
253
|
+
msg[:delivery_count] += 1
|
|
254
|
+
|
|
255
|
+
MockMessage.new(
|
|
256
|
+
subject: msg[:subject],
|
|
257
|
+
data: msg[:data],
|
|
258
|
+
header: msg[:header],
|
|
259
|
+
sequence: msg[:sequence],
|
|
260
|
+
stream: stream_name,
|
|
261
|
+
consumer: durable_name,
|
|
262
|
+
num_delivered: msg[:delivery_count],
|
|
263
|
+
storage: self,
|
|
264
|
+
message_ref: msg
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def ack_message(message_ref)
|
|
271
|
+
@mutex.synchronize do
|
|
272
|
+
@messages.delete(message_ref)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def nak_message(_message_ref, delay: nil)
|
|
277
|
+
@mutex.synchronize do
|
|
278
|
+
# Message stays in queue for redelivery
|
|
279
|
+
# Note: delivery_count was already incremented during fetch
|
|
280
|
+
# We don't decrement it here as it represents actual delivery attempts
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def term_message(message_ref)
|
|
285
|
+
@mutex.synchronize do
|
|
286
|
+
@messages.delete(message_ref)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def add_stream(config)
|
|
291
|
+
@mutex.synchronize do
|
|
292
|
+
name = config[:name] || config['name']
|
|
293
|
+
@streams[name] = MockStream.new(name, config)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def delete_stream(name)
|
|
298
|
+
@mutex.synchronize do
|
|
299
|
+
@streams.delete(name)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def find_stream(name)
|
|
304
|
+
@streams[name]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def find_stream_for_subject(_subject)
|
|
308
|
+
@streams.values.first # Simplified: return first stream
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def find_consumer(stream_name, durable_name)
|
|
312
|
+
consumer = @consumers[durable_name]
|
|
313
|
+
return nil unless consumer
|
|
314
|
+
return nil unless consumer.stream == stream_name
|
|
315
|
+
|
|
316
|
+
consumer
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def add_consumer(stream_name, config)
|
|
320
|
+
@mutex.synchronize do
|
|
321
|
+
durable_name = config[:durable_name] || config['durable_name']
|
|
322
|
+
@consumers[durable_name] = MockConsumer.new(
|
|
323
|
+
name: durable_name,
|
|
324
|
+
stream: stream_name,
|
|
325
|
+
config: config
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def delete_consumer(stream_name, consumer_name)
|
|
331
|
+
@mutex.synchronize do
|
|
332
|
+
consumer = @consumers[consumer_name]
|
|
333
|
+
@consumers.delete(consumer_name) if consumer&.stream == stream_name
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def reset!
|
|
338
|
+
@mutex.synchronize do
|
|
339
|
+
@streams.clear
|
|
340
|
+
@consumers.clear
|
|
341
|
+
@messages.clear
|
|
342
|
+
@subscriptions.clear
|
|
343
|
+
@sequence_counter = 0
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
private
|
|
348
|
+
|
|
349
|
+
def max_deliver_for(durable_name)
|
|
350
|
+
consumer = @consumers[durable_name]
|
|
351
|
+
return 5 unless consumer # Default
|
|
352
|
+
|
|
353
|
+
consumer.config[:max_deliver] || consumer.config['max_deliver'] || 5
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
class MockAck
|
|
358
|
+
attr_reader :sequence, :stream, :error
|
|
359
|
+
|
|
360
|
+
def initialize(duplicate:, sequence:, stream:)
|
|
361
|
+
@duplicate = duplicate
|
|
362
|
+
@sequence = sequence
|
|
363
|
+
@stream = stream
|
|
364
|
+
@error = nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def duplicate?
|
|
368
|
+
@duplicate
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
class MockMessage
|
|
373
|
+
attr_reader :subject, :data, :header, :sequence, :stream, :consumer, :num_delivered
|
|
374
|
+
|
|
375
|
+
def initialize(subject:, data:, header:, sequence:, stream:, consumer:, num_delivered:, storage:, message_ref:)
|
|
376
|
+
@subject = subject
|
|
377
|
+
@data = data
|
|
378
|
+
@header = header
|
|
379
|
+
@sequence = sequence
|
|
380
|
+
@stream = stream
|
|
381
|
+
@consumer = consumer
|
|
382
|
+
@num_delivered = num_delivered
|
|
383
|
+
@storage = storage
|
|
384
|
+
@message_ref = message_ref
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def metadata
|
|
388
|
+
OpenStruct.new(
|
|
389
|
+
sequence: OpenStruct.new(stream: @sequence, consumer: @sequence),
|
|
390
|
+
num_delivered: @num_delivered,
|
|
391
|
+
stream: @stream,
|
|
392
|
+
consumer: @consumer,
|
|
393
|
+
timestamp: Time.now
|
|
394
|
+
)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def ack
|
|
398
|
+
@storage.ack_message(@message_ref)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def nak(in_progress_duration: nil)
|
|
402
|
+
@storage.nak_message(@message_ref, delay: in_progress_duration)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def term
|
|
406
|
+
@storage.term_message(@message_ref)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
class MockSubscription
|
|
411
|
+
attr_reader :subject, :durable_name, :stream_name
|
|
412
|
+
|
|
413
|
+
def initialize(subject:, durable_name:, storage:, stream_name:, options:)
|
|
414
|
+
@subject = subject
|
|
415
|
+
@durable_name = durable_name
|
|
416
|
+
@storage = storage
|
|
417
|
+
@stream_name = stream_name
|
|
418
|
+
@options = options
|
|
419
|
+
@unsubscribed = false
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def fetch(batch_size, timeout: 5)
|
|
423
|
+
raise NATS::JetStream::Error, 'consumer not found' if @unsubscribed
|
|
424
|
+
|
|
425
|
+
@storage.fetch_messages(@subject, @durable_name, batch_size, timeout)
|
|
426
|
+
rescue StandardError => e
|
|
427
|
+
raise NATS::IO::Timeout if timeout && e.is_a?(Timeout::Error)
|
|
428
|
+
|
|
429
|
+
raise
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def unsubscribe
|
|
433
|
+
@unsubscribed = true
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
class MockStream
|
|
438
|
+
attr_reader :name, :config
|
|
439
|
+
|
|
440
|
+
def initialize(name, config)
|
|
441
|
+
@name = name
|
|
442
|
+
@config = config
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def info
|
|
446
|
+
OpenStruct.new(
|
|
447
|
+
config: OpenStruct.new(
|
|
448
|
+
name: @name,
|
|
449
|
+
subjects: @config[:subjects] || [@name],
|
|
450
|
+
retention: @config[:retention] || 'limits',
|
|
451
|
+
max_consumers: @config[:max_consumers] || -1,
|
|
452
|
+
max_msgs: @config[:max_msgs] || -1,
|
|
453
|
+
max_bytes: @config[:max_bytes] || -1,
|
|
454
|
+
discard: @config[:discard] || 'old',
|
|
455
|
+
max_age: @config[:max_age] || 0,
|
|
456
|
+
max_msgs_per_subject: @config[:max_msgs_per_subject] || -1,
|
|
457
|
+
max_msg_size: @config[:max_msg_size] || -1,
|
|
458
|
+
storage: @config[:storage] || 'file',
|
|
459
|
+
num_replicas: @config[:num_replicas] || 1
|
|
460
|
+
),
|
|
461
|
+
state: OpenStruct.new(
|
|
462
|
+
messages: 0,
|
|
463
|
+
bytes: 0,
|
|
464
|
+
first_seq: 1,
|
|
465
|
+
last_seq: 0,
|
|
466
|
+
consumer_count: 0
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
class MockConsumer
|
|
473
|
+
attr_reader :name, :stream, :config
|
|
474
|
+
|
|
475
|
+
def initialize(name:, stream:, config:)
|
|
476
|
+
@name = name
|
|
477
|
+
@stream = stream
|
|
478
|
+
@config = config
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def info
|
|
482
|
+
OpenStruct.new(
|
|
483
|
+
name: @name,
|
|
484
|
+
stream_name: @stream,
|
|
485
|
+
config: OpenStruct.new(
|
|
486
|
+
durable_name: @name,
|
|
487
|
+
ack_policy: @config[:ack_policy] || 'explicit',
|
|
488
|
+
max_deliver: @config[:max_deliver] || 5,
|
|
489
|
+
ack_wait: @config[:ack_wait] || 30_000_000_000,
|
|
490
|
+
filter_subject: @config[:filter_subject] || '',
|
|
491
|
+
replay_policy: @config[:replay_policy] || 'instant'
|
|
492
|
+
),
|
|
493
|
+
num_pending: 0,
|
|
494
|
+
num_delivered: 0
|
|
495
|
+
)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Mock API response message
|
|
500
|
+
# Simulates NATS::Msg for JetStream API responses
|
|
501
|
+
class MockApiResponse
|
|
502
|
+
attr_reader :data
|
|
503
|
+
|
|
504
|
+
def initialize(data)
|
|
505
|
+
@data = data
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Factory method to create a mock connection
|
|
510
|
+
def self.create_mock_connection
|
|
511
|
+
MockConnection.new
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Global storage accessor for testing
|
|
515
|
+
def self.storage
|
|
516
|
+
@storage ||= InMemoryStorage.new
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def self.reset!
|
|
520
|
+
@storage&.reset!
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|