jetstream_bridge 4.1.0 → 4.3.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.
data/docs/TESTING.md ADDED
@@ -0,0 +1,414 @@
1
+ # Testing with Mock NATS
2
+
3
+ JetstreamBridge provides a comprehensive in-memory mock for NATS JetStream that allows you to test your application without requiring a real NATS server.
4
+
5
+ ## Overview
6
+
7
+ The mock NATS implementation simulates:
8
+
9
+ - NATS connection lifecycle (connect, disconnect, reconnect)
10
+ - JetStream publish/subscribe operations
11
+ - Message acknowledgment (ACK, NAK, TERM)
12
+ - Consumer durable state and message redelivery
13
+ - Duplicate message detection
14
+ - Stream and consumer management
15
+ - Error scenarios
16
+
17
+ ## Basic Usage
18
+
19
+ ### With Test Helpers (Recommended)
20
+
21
+ The easiest way to use the mock is through the test helpers. The mock automatically integrates with the Connection class, so no additional mocking is needed:
22
+
23
+ ```ruby
24
+ require 'jetstream_bridge/test_helpers'
25
+
26
+ RSpec.describe MyService do
27
+ include JetstreamBridge::TestHelpers
28
+ include JetstreamBridge::TestHelpers::Matchers
29
+
30
+ before do
31
+ # Reset singleton to ensure clean state
32
+ JetstreamBridge::Connection.instance_variable_set(:@singleton__instance__, nil)
33
+ JetstreamBridge.reset!
34
+
35
+ # Enable test mode - automatically sets up mock NATS
36
+ JetstreamBridge::TestHelpers.enable_test_mode!
37
+
38
+ JetstreamBridge.configure do |config|
39
+ config.env = 'test'
40
+ config.app_name = 'my_app'
41
+ config.destination_app = 'worker'
42
+ end
43
+
44
+ # Setup mock stream and stub topology
45
+ mock_jts = JetstreamBridge::TestHelpers.mock_connection.jetstream
46
+ mock_jts.add_stream(
47
+ name: 'test-jetstream-bridge-stream',
48
+ subjects: ['test.>']
49
+ )
50
+ allow(JetstreamBridge::Topology).to receive(:ensure!)
51
+ end
52
+
53
+ after do
54
+ JetstreamBridge::TestHelpers.reset_test_mode!
55
+ JetstreamBridge::Connection.instance_variable_set(:@singleton__instance__, nil)
56
+ end
57
+
58
+ it 'publishes events through the full stack' do
59
+ result = JetstreamBridge.publish(
60
+ event_type: 'user.created',
61
+ resource_type: 'user',
62
+ payload: { id: 1, name: 'Ada' }
63
+ )
64
+
65
+ expect(result.success?).to be true
66
+ expect(result.duplicate?).to be false
67
+
68
+ # Can also use matchers (requires test mode)
69
+ expect(JetstreamBridge).to have_published(
70
+ event_type: 'user.created',
71
+ payload: hash_including(name: 'Ada')
72
+ )
73
+ end
74
+ end
75
+ ```
76
+
77
+ ### Manual Mock Setup
78
+
79
+ For more control, you can manually set up the mock:
80
+
81
+ ```ruby
82
+ require 'jetstream_bridge/test_helpers/mock_nats'
83
+
84
+ RSpec.describe 'Publishing' do
85
+ let(:mock_connection) { JetstreamBridge::TestHelpers::MockNats.create_mock_connection }
86
+ let(:jetstream) { mock_connection.jetstream }
87
+
88
+ before do
89
+ mock_connection.connect
90
+ allow(NATS::IO::Client).to receive(:new).and_return(mock_connection)
91
+ end
92
+
93
+ it 'publishes a message' do
94
+ ack = jetstream.publish(
95
+ 'test.subject',
96
+ { user_id: 1 }.to_json,
97
+ header: { 'nats-msg-id' => 'unique-id' }
98
+ )
99
+
100
+ expect(ack.duplicate?).to be false
101
+ expect(ack.sequence).to eq(1)
102
+ end
103
+ end
104
+ ```
105
+
106
+ ## Features
107
+
108
+ ### Publishing Events
109
+
110
+ The mock tracks all published messages and supports duplicate detection:
111
+
112
+ ```ruby
113
+ mock_jts = mock_connection.jetstream
114
+
115
+ # First publish
116
+ ack1 = mock_jts.publish('subject', data, header: { 'nats-msg-id' => 'id-1' })
117
+ expect(ack1.duplicate?).to be false
118
+
119
+ # Duplicate publish
120
+ ack2 = mock_jts.publish('subject', data, header: { 'nats-msg-id' => 'id-1' })
121
+ expect(ack2.duplicate?).to be true
122
+ ```
123
+
124
+ ### Consuming Events
125
+
126
+ Create subscriptions and fetch messages:
127
+
128
+ ```ruby
129
+ # Publish messages
130
+ 3.times do |i|
131
+ jetstream.publish(
132
+ 'test.subject',
133
+ { msg: i }.to_json,
134
+ header: { 'nats-msg-id' => "msg-#{i}" }
135
+ )
136
+ end
137
+
138
+ # Subscribe and fetch
139
+ subscription = jetstream.pull_subscribe('test.subject', 'consumer', stream: 'test-stream')
140
+ messages = subscription.fetch(10, timeout: 1)
141
+
142
+ expect(messages.size).to eq(3)
143
+
144
+ # Process and acknowledge
145
+ messages.each do |msg|
146
+ data = Oj.load(msg.data)
147
+ process(data)
148
+ msg.ack
149
+ end
150
+ ```
151
+
152
+ ### Message Acknowledgment
153
+
154
+ The mock supports all acknowledgment types:
155
+
156
+ ```ruby
157
+ message = subscription.fetch(1).first
158
+
159
+ # Positive acknowledgment (removes from queue)
160
+ message.ack
161
+
162
+ # Negative acknowledgment (keeps in queue for redelivery)
163
+ message.nak
164
+
165
+ # Terminate (removes from queue without processing)
166
+ message.term
167
+ ```
168
+
169
+ ### Redelivery and max_deliver
170
+
171
+ The mock respects `max_deliver` settings:
172
+
173
+ ```ruby
174
+ subscription = jetstream.pull_subscribe(
175
+ 'test.subject',
176
+ 'consumer',
177
+ stream: 'test-stream',
178
+ max_deliver: 3
179
+ )
180
+
181
+ # Publish a message
182
+ jetstream.publish('test.subject', 'data', header: { 'nats-msg-id' => 'id-1' })
183
+
184
+ # Attempt 1
185
+ msg = subscription.fetch(1).first
186
+ expect(msg.metadata.num_delivered).to eq(1)
187
+ msg.nak
188
+
189
+ # Attempt 2
190
+ msg = subscription.fetch(1).first
191
+ expect(msg.metadata.num_delivered).to eq(2)
192
+ msg.nak
193
+
194
+ # Attempt 3
195
+ msg = subscription.fetch(1).first
196
+ expect(msg.metadata.num_delivered).to eq(3)
197
+ msg.nak
198
+
199
+ # Attempt 4 - empty (exceeded max_deliver)
200
+ msgs = subscription.fetch(1)
201
+ expect(msgs).to be_empty
202
+ ```
203
+
204
+ ### Stream and Consumer Management
205
+
206
+ ```ruby
207
+ # Add a stream
208
+ jetstream.add_stream(
209
+ name: 'my-stream',
210
+ subjects: ['my.subject.>']
211
+ )
212
+
213
+ # Get stream info
214
+ info = jetstream.stream_info('my-stream')
215
+ expect(info.config.name).to eq('my-stream')
216
+
217
+ # Get consumer info
218
+ subscription = jetstream.pull_subscribe('my.subject', 'consumer', stream: 'my-stream')
219
+ info = jetstream.consumer_info('my-stream', 'consumer')
220
+ expect(info.name).to eq('consumer')
221
+ ```
222
+
223
+ ### Connection Lifecycle
224
+
225
+ Simulate connection events:
226
+
227
+ ```ruby
228
+ mock_connection = JetstreamBridge::TestHelpers::MockNats.create_mock_connection
229
+
230
+ # Register callbacks
231
+ reconnect_called = false
232
+ mock_connection.on_reconnect { reconnect_called = true }
233
+
234
+ # Simulate events
235
+ mock_connection.simulate_disconnect!
236
+ expect(mock_connection.connected?).to be false
237
+
238
+ mock_connection.simulate_reconnect!
239
+ expect(reconnect_called).to be true
240
+ ```
241
+
242
+ ### Error Scenarios
243
+
244
+ The mock can simulate various error conditions:
245
+
246
+ ```ruby
247
+ # JetStream not available
248
+ disconnected_conn = JetstreamBridge::TestHelpers::MockNats.create_mock_connection
249
+ expect { disconnected_conn.jetstream }.to raise_error(NATS::IO::NoRespondersError)
250
+
251
+ # Stream not found
252
+ expect do
253
+ jetstream.stream_info('nonexistent')
254
+ end.to raise_error(NATS::JetStream::Error, 'stream not found')
255
+
256
+ # Consumer not found
257
+ expect do
258
+ jetstream.consumer_info('test-stream', 'nonexistent')
259
+ end.to raise_error(NATS::JetStream::Error, 'consumer not found')
260
+ ```
261
+
262
+ ## Integration with JetstreamBridge
263
+
264
+ ### Full Publishing Flow
265
+
266
+ ```ruby
267
+ before do
268
+ JetstreamBridge.reset!
269
+
270
+ mock_conn = JetstreamBridge::TestHelpers::MockNats.create_mock_connection
271
+ mock_jts = mock_conn.jetstream
272
+
273
+ allow(NATS::IO::Client).to receive(:new).and_return(mock_conn)
274
+ allow(JetstreamBridge::Connection).to receive(:connect!).and_call_original
275
+
276
+ # Setup stream
277
+ mock_jts.add_stream(
278
+ name: 'test-jetstream-bridge-stream',
279
+ subjects: ['test.>']
280
+ )
281
+
282
+ # Allow topology check to succeed
283
+ allow(JetstreamBridge::Topology).to receive(:ensure!)
284
+
285
+ JetstreamBridge.configure do |config|
286
+ config.env = 'test'
287
+ config.app_name = 'api'
288
+ config.destination_app = 'worker'
289
+ end
290
+ end
291
+
292
+ it 'publishes through JetstreamBridge' do
293
+ result = JetstreamBridge.publish(
294
+ event_type: 'user.created',
295
+ resource_type: 'user',
296
+ payload: { id: 1, name: 'Test' }
297
+ )
298
+
299
+ expect(result).to be_publish_success
300
+ expect(result.event_id).to be_present
301
+ expect(result.subject).to eq('test.api.sync.worker')
302
+
303
+ # Verify in storage
304
+ storage = JetstreamBridge::TestHelpers.mock_storage
305
+ expect(storage.messages.size).to eq(1)
306
+ end
307
+ ```
308
+
309
+ ### Full Consuming Flow
310
+
311
+ ```ruby
312
+ it 'consumes through JetstreamBridge' do
313
+ mock_conn = JetstreamBridge::TestHelpers.mock_connection
314
+ mock_jts = mock_conn.jetstream
315
+
316
+ # Publish message to destination subject
317
+ mock_jts.publish(
318
+ 'test.worker.sync.api',
319
+ Oj.dump({
320
+ 'event_id' => 'event-1',
321
+ 'schema_version' => 1,
322
+ 'event_type' => 'task.created',
323
+ 'producer' => 'api',
324
+ 'resource_id' => '1',
325
+ 'resource_type' => 'task',
326
+ 'occurred_at' => Time.now.utc.iso8601,
327
+ 'trace_id' => SecureRandom.hex(8),
328
+ 'payload' => { id: 1, title: 'Task 1' }
329
+ }),
330
+ header: { 'nats-msg-id' => 'event-1' }
331
+ )
332
+
333
+ # Create consumer
334
+ events_received = []
335
+ consumer = JetstreamBridge::Consumer.new(batch_size: 10) do |event|
336
+ events_received << event
337
+ end
338
+
339
+ # Mock subscription
340
+ subscription = mock_jts.pull_subscribe(
341
+ 'test.worker.sync.api',
342
+ 'test-consumer',
343
+ stream: 'test-jetstream-bridge-stream'
344
+ )
345
+
346
+ allow_any_instance_of(JetstreamBridge::SubscriptionManager)
347
+ .to receive(:subscribe!)
348
+ .and_return(subscription)
349
+
350
+ # Process one batch
351
+ allow(consumer).to receive(:stop_requested?).and_return(false, true)
352
+ consumer.run!
353
+
354
+ expect(events_received.size).to eq(1)
355
+ expect(events_received.first.type).to eq('task.created')
356
+ end
357
+ ```
358
+
359
+ ## Direct Storage Access
360
+
361
+ For advanced testing, you can access the mock storage directly:
362
+
363
+ ```ruby
364
+ storage = JetstreamBridge::TestHelpers.mock_storage
365
+
366
+ # Inspect all messages
367
+ storage.messages.each do |msg|
368
+ puts "Subject: #{msg[:subject]}"
369
+ puts "Data: #{msg[:data]}"
370
+ puts "Delivery count: #{msg[:delivery_count]}"
371
+ end
372
+
373
+ # Check streams
374
+ storage.streams.each do |name, stream|
375
+ puts "Stream: #{name}"
376
+ end
377
+
378
+ # Check consumers
379
+ storage.consumers.each do |name, consumer|
380
+ puts "Consumer: #{name} on stream #{consumer.stream}"
381
+ end
382
+
383
+ # Reset storage
384
+ storage.reset!
385
+ ```
386
+
387
+ ## Best Practices
388
+
389
+ 1. **Reset state between tests**: Always call `reset_test_mode!` in your `after` hooks
390
+ 2. **Use test helpers when possible**: They handle most of the mocking setup for you
391
+ 3. **Test both success and failure paths**: Use the mock to simulate errors
392
+ 4. **Verify message content**: Check that envelopes are correctly formatted
393
+ 5. **Test idempotency**: Verify duplicate detection and redelivery behavior
394
+ 6. **Mock topology setup**: Remember to stub `JetstreamBridge::Topology.ensure!`
395
+
396
+ ## Examples
397
+
398
+ See the comprehensive test examples in:
399
+
400
+ - [spec/test_helpers/mock_nats_spec.rb](../spec/test_helpers/mock_nats_spec.rb)
401
+ - [spec/integration/mock_integration_spec.rb](../spec/integration/mock_integration_spec.rb)
402
+ - [spec/integration/mock_connection_integration_spec.rb](../spec/integration/mock_connection_integration_spec.rb)
403
+
404
+ ## Limitations
405
+
406
+ The mock is designed for testing and has some simplifications:
407
+
408
+ - No actual network operations
409
+ - Simplified stream subject matching (uses first stream)
410
+ - No persistent storage between test runs
411
+ - Simplified timing/timeout behavior
412
+ - No cluster or failover simulation
413
+
414
+ For integration tests that require a real NATS server, consider using Docker to run NATS in CI/CD environments.
@@ -107,11 +107,13 @@ module JetstreamBridge
107
107
  @start_time = Time.now
108
108
  @iterations = 0
109
109
  @last_health_check = Time.now
110
- # Use existing connection or establish one
111
- @jts = Connection.jetstream || Connection.connect!
110
+ # Use existing connection (should already be established)
111
+ @jts = Connection.jetstream
112
+ raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
113
+
112
114
  @middleware_chain = MiddlewareChain.new
113
115
 
114
- ensure_destination!
116
+ ensure_destination_app_configured!
115
117
 
116
118
  @sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
117
119
  @processor = MessageProcessor.new(@jts, @handler, middleware_chain: @middleware_chain)
@@ -242,7 +244,7 @@ module JetstreamBridge
242
244
 
243
245
  private
244
246
 
245
- def ensure_destination!
247
+ def ensure_destination_app_configured!
246
248
  return unless JetstreamBridge.config.destination_app.to_s.empty?
247
249
 
248
250
  raise ArgumentError, 'destination_app must be configured'
@@ -284,6 +286,7 @@ module JetstreamBridge
284
286
  rescue StandardError => e
285
287
  # Safety: never let a single bad message kill the batch loop.
286
288
  Logging.error("Message processing crashed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
289
+ safe_nak_message(msg)
287
290
  0
288
291
  end
289
292
 
@@ -431,5 +434,14 @@ module JetstreamBridge
431
434
  rescue StandardError => e
432
435
  Logging.error("Drain failed: #{e.class} #{e.message}", tag: 'JetstreamBridge::Consumer')
433
436
  end
437
+
438
+ def safe_nak_message(msg)
439
+ return unless msg.respond_to?(:nak)
440
+
441
+ msg.nak
442
+ rescue StandardError => e
443
+ Logging.error("Failed to NAK message after crash: #{e.class} #{e.message}",
444
+ tag: 'JetstreamBridge::Consumer')
445
+ end
434
446
  end
435
447
  end
@@ -27,13 +27,27 @@ module JetstreamBridge
27
27
  end
28
28
 
29
29
  repo.persist_pre(record, msg)
30
- @processor.handle_message(msg)
31
- repo.persist_post(record)
32
- true
30
+ action = @processor.handle_message(msg, auto_ack: false)
31
+
32
+ case action&.action
33
+ when :ack
34
+ repo.persist_post(record)
35
+ @processor.send(:apply_action, msg, action)
36
+ true
37
+ when :nak
38
+ repo.persist_failure(record, action.error || StandardError.new('Inbox processing failed'))
39
+ @processor.send(:apply_action, msg, action)
40
+ false
41
+ else
42
+ repo.persist_failure(record, StandardError.new('Inbox processing returned no action'))
43
+ false
44
+ end
33
45
  rescue StandardError => e
34
46
  repo.persist_failure(record, e) if repo && record
35
47
  Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
36
48
  tag: 'JetstreamBridge::Consumer')
49
+ # Ensure the message is retried if possible
50
+ @processor.send(:safe_nak, msg, nil, e, delay: nil) if msg.respond_to?(:nak)
37
51
  false
38
52
  end
39
53
 
@@ -11,13 +11,15 @@ module JetstreamBridge
11
11
  end
12
12
 
13
13
  def find_or_build(msg)
14
- if ModelUtils.has_columns?(@klass, :event_id)
15
- @klass.find_or_initialize_by(event_id: msg.event_id)
16
- elsif ModelUtils.has_columns?(@klass, :stream_seq)
17
- @klass.find_or_initialize_by(stream_seq: msg.seq)
18
- else
19
- @klass.new
20
- end
14
+ record = if ModelUtils.has_columns?(@klass, :event_id)
15
+ @klass.find_or_initialize_by(event_id: msg.event_id)
16
+ elsif ModelUtils.has_columns?(@klass, :stream_seq)
17
+ @klass.find_or_initialize_by(stream_seq: msg.seq)
18
+ else
19
+ @klass.new
20
+ end
21
+
22
+ lock_record(record)
21
23
  end
22
24
 
23
25
  def already_processed?(record)
@@ -74,5 +76,15 @@ module JetstreamBridge
74
76
  Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
75
77
  tag: 'JetstreamBridge::Consumer')
76
78
  end
79
+
80
+ def lock_record(record)
81
+ return record unless record.respond_to?(:persisted?) && record.persisted?
82
+ return record unless record.respond_to?(:lock!)
83
+
84
+ record.lock!
85
+ record
86
+ rescue ActiveRecord::RecordNotFound
87
+ @klass.new
88
+ end
77
89
  end
78
90
  end