jetstream_bridge 3.0.2 → 4.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 +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +1147 -82
- data/lib/jetstream_bridge/consumer/consumer.rb +174 -6
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/message_processor.rb +41 -7
- data/lib/jetstream_bridge/consumer/middleware.rb +154 -0
- data/lib/jetstream_bridge/core/config.rb +150 -9
- data/lib/jetstream_bridge/core/config_preset.rb +99 -0
- data/lib/jetstream_bridge/core/connection.rb +5 -2
- data/lib/jetstream_bridge/core/connection_factory.rb +1 -1
- data/lib/jetstream_bridge/core/duration.rb +19 -35
- data/lib/jetstream_bridge/errors.rb +60 -8
- data/lib/jetstream_bridge/models/event.rb +202 -0
- data/lib/jetstream_bridge/{inbox_event.rb → models/inbox_event.rb} +61 -3
- data/lib/jetstream_bridge/{outbox_event.rb → models/outbox_event.rb} +64 -15
- data/lib/jetstream_bridge/models/publish_result.rb +64 -0
- data/lib/jetstream_bridge/models/subject.rb +53 -2
- data/lib/jetstream_bridge/publisher/batch_publisher.rb +163 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +238 -19
- data/lib/jetstream_bridge/test_helpers.rb +275 -0
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +178 -3
- data/lib/tasks/yard.rake +18 -0
- metadata +11 -4
|
@@ -7,33 +7,185 @@ require_relative '../core/logging'
|
|
|
7
7
|
require_relative '../core/config'
|
|
8
8
|
require_relative '../core/model_utils'
|
|
9
9
|
require_relative '../core/retry_strategy'
|
|
10
|
+
require_relative '../models/publish_result'
|
|
10
11
|
require_relative 'outbox_repository'
|
|
11
12
|
|
|
12
13
|
module JetstreamBridge
|
|
13
|
-
# Publishes to
|
|
14
|
+
# Publishes events to NATS JetStream with reliability features.
|
|
15
|
+
#
|
|
16
|
+
# Publishes events to "{env}.{app}.sync.{dest}" subject pattern.
|
|
17
|
+
# Supports optional transactional outbox pattern for guaranteed delivery.
|
|
18
|
+
#
|
|
19
|
+
# @example Basic publishing
|
|
20
|
+
# publisher = JetstreamBridge::Publisher.new
|
|
21
|
+
# result = publisher.publish(
|
|
22
|
+
# resource_type: "user",
|
|
23
|
+
# event_type: "created",
|
|
24
|
+
# payload: { id: 1, email: "ada@example.com" }
|
|
25
|
+
# )
|
|
26
|
+
# puts "Published: #{result.event_id}" if result.success?
|
|
27
|
+
#
|
|
28
|
+
# @example Publishing with custom retry strategy
|
|
29
|
+
# custom_strategy = MyRetryStrategy.new(max_attempts: 5)
|
|
30
|
+
# publisher = JetstreamBridge::Publisher.new(retry_strategy: custom_strategy)
|
|
31
|
+
# result = publisher.publish(event_type: "user.created", payload: { id: 1 })
|
|
32
|
+
#
|
|
33
|
+
# @example Using convenience method
|
|
34
|
+
# JetstreamBridge.publish(event_type: "user.created", payload: { id: 1 })
|
|
35
|
+
#
|
|
14
36
|
class Publisher
|
|
37
|
+
# Initialize a new Publisher instance.
|
|
38
|
+
#
|
|
39
|
+
# @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
|
|
40
|
+
# Defaults to PublisherRetryStrategy with exponential backoff.
|
|
41
|
+
# @raise [ConnectionError] If unable to connect to NATS server
|
|
15
42
|
def initialize(retry_strategy: nil)
|
|
16
43
|
@jts = Connection.connect!
|
|
17
44
|
@retry_strategy = retry_strategy || PublisherRetryStrategy.new
|
|
18
45
|
end
|
|
19
46
|
|
|
20
|
-
#
|
|
21
|
-
|
|
47
|
+
# Publishes an event to NATS JetStream.
|
|
48
|
+
#
|
|
49
|
+
# Supports multiple usage patterns for flexibility:
|
|
50
|
+
#
|
|
51
|
+
# 1. Structured parameters (recommended):
|
|
52
|
+
# publish(resource_type: 'user', event_type: 'created', payload: { id: 1, name: 'Ada' })
|
|
53
|
+
#
|
|
54
|
+
# 2. Hash/envelope with dot notation (auto-infers resource_type):
|
|
55
|
+
# publish(event_type: 'user.created', payload: {...})
|
|
56
|
+
#
|
|
57
|
+
# 3. Complete envelope (advanced):
|
|
58
|
+
# publish({ event_type: 'created', resource_type: 'user', payload: {...}, event_id: '...' })
|
|
59
|
+
#
|
|
60
|
+
# When use_outbox is enabled, events are persisted to database first for reliability.
|
|
61
|
+
# The event_id is used for deduplication via NATS message ID header.
|
|
62
|
+
#
|
|
63
|
+
# @param event_or_hash [Hash, nil] Complete event envelope (if using pattern 3)
|
|
64
|
+
# @param resource_type [String, nil] Resource type (e.g., 'user', 'order'). Required for pattern 1.
|
|
65
|
+
# @param event_type [String, nil] Event type (e.g., 'created', 'user.created'). Required for all patterns.
|
|
66
|
+
# @param payload [Hash, nil] Event payload data. Required for all patterns.
|
|
67
|
+
# @param subject [String, nil] Optional NATS subject override. Defaults to config.source_subject.
|
|
68
|
+
# @param options [Hash] Additional options:
|
|
69
|
+
# - event_id [String] Custom event ID (auto-generated if not provided)
|
|
70
|
+
# - trace_id [String] Distributed trace ID
|
|
71
|
+
# - occurred_at [Time, String] Event timestamp (defaults to current time)
|
|
72
|
+
#
|
|
73
|
+
# @return [Models::PublishResult] Result object containing:
|
|
74
|
+
# - success [Boolean] Whether publish succeeded
|
|
75
|
+
# - event_id [String] The published event ID
|
|
76
|
+
# - subject [String] NATS subject used
|
|
77
|
+
# - error [Exception, nil] Error if publish failed
|
|
78
|
+
# - duplicate [Boolean] Whether NATS detected as duplicate
|
|
79
|
+
#
|
|
80
|
+
# @raise [ArgumentError] If required parameters are missing or invalid
|
|
81
|
+
#
|
|
82
|
+
# @example Structured parameters
|
|
83
|
+
# result = publisher.publish(
|
|
84
|
+
# resource_type: "user",
|
|
85
|
+
# event_type: "created",
|
|
86
|
+
# payload: { id: 1, email: "ada@example.com" }
|
|
87
|
+
# )
|
|
88
|
+
# puts "Published: #{result.event_id}" if result.success?
|
|
89
|
+
#
|
|
90
|
+
# @example With options
|
|
91
|
+
# result = publisher.publish(
|
|
92
|
+
# event_type: "user.created",
|
|
93
|
+
# payload: { id: 1 },
|
|
94
|
+
# event_id: "custom-id-123",
|
|
95
|
+
# trace_id: request_id,
|
|
96
|
+
# occurred_at: Time.now.utc
|
|
97
|
+
# )
|
|
98
|
+
#
|
|
99
|
+
# @example Error handling
|
|
100
|
+
# result = publisher.publish(event_type: "order.created", payload: { id: 1 })
|
|
101
|
+
# if result.failure?
|
|
102
|
+
# logger.error "Failed to publish: #{result.error.message}"
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **options)
|
|
22
106
|
ensure_destination!
|
|
23
|
-
envelope = build_envelope(resource_type, event_type, payload, options)
|
|
24
|
-
subject = JetstreamBridge.config.source_subject
|
|
25
107
|
|
|
108
|
+
params = { event_or_hash: event_or_hash, resource_type: resource_type, event_type: event_type,
|
|
109
|
+
payload: payload, subject: subject, options: options }
|
|
110
|
+
envelope, resolved_subject = route_publish_params(params)
|
|
111
|
+
|
|
112
|
+
do_publish(resolved_subject, envelope)
|
|
113
|
+
rescue ArgumentError
|
|
114
|
+
# Re-raise validation errors for invalid parameters
|
|
115
|
+
raise
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
# Return failure result for publishing errors
|
|
118
|
+
Models::PublishResult.new(
|
|
119
|
+
success: false,
|
|
120
|
+
event_id: envelope&.[]('event_id') || 'unknown',
|
|
121
|
+
subject: resolved_subject || 'unknown',
|
|
122
|
+
error: e
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Internal publish method that routes to appropriate publish strategy.
|
|
127
|
+
#
|
|
128
|
+
# Routes to outbox-based publishing if use_outbox is enabled, otherwise
|
|
129
|
+
# publishes directly to NATS with retry logic.
|
|
130
|
+
#
|
|
131
|
+
# @param subject [String] NATS subject to publish to
|
|
132
|
+
# @param envelope [Hash] Complete event envelope
|
|
133
|
+
# @return [Models::PublishResult] Result object
|
|
134
|
+
# @api private
|
|
135
|
+
def do_publish(subject, envelope)
|
|
26
136
|
if JetstreamBridge.config.use_outbox
|
|
27
137
|
publish_via_outbox(subject, envelope)
|
|
28
138
|
else
|
|
29
139
|
with_retries { publish_to_nats(subject, envelope) }
|
|
30
140
|
end
|
|
31
|
-
rescue StandardError => e
|
|
32
|
-
log_error(false, e)
|
|
33
141
|
end
|
|
34
142
|
|
|
35
143
|
private
|
|
36
144
|
|
|
145
|
+
# Routes publish parameters to appropriate envelope builder
|
|
146
|
+
# @return [Array<Hash, String>] tuple of [envelope, subject]
|
|
147
|
+
def route_publish_params(params)
|
|
148
|
+
if structured_params?(params)
|
|
149
|
+
build_from_structured_params(params)
|
|
150
|
+
elsif keyword_or_hash_params?(params)
|
|
151
|
+
build_from_keyword_or_hash(params)
|
|
152
|
+
else
|
|
153
|
+
raise ArgumentError, 'Either provide (resource_type:, event_type:, payload:) or an event hash'
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def structured_params?(params)
|
|
158
|
+
params[:resource_type] && params[:event_type] && params[:payload]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def keyword_or_hash_params?(params)
|
|
162
|
+
params[:event_type] || params[:payload] || params[:event_or_hash].is_a?(Hash)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def build_from_structured_params(params)
|
|
166
|
+
envelope = build_envelope(params[:resource_type], params[:event_type], params[:payload], params[:options])
|
|
167
|
+
resolved_subject = params[:subject] || JetstreamBridge.config.source_subject
|
|
168
|
+
[envelope, resolved_subject]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def build_from_keyword_or_hash(params)
|
|
172
|
+
envelope = if params[:event_or_hash].is_a?(Hash)
|
|
173
|
+
normalize_envelope(params[:event_or_hash], params[:options])
|
|
174
|
+
else
|
|
175
|
+
build_from_keywords(params[:event_type], params[:payload], params[:options])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
resolved_subject = params[:subject] || params[:options][:subject] || JetstreamBridge.config.source_subject
|
|
179
|
+
[envelope, resolved_subject]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_from_keywords(event_type, payload, options)
|
|
183
|
+
raise ArgumentError, 'event_type is required' unless event_type
|
|
184
|
+
raise ArgumentError, 'payload is required' unless payload
|
|
185
|
+
|
|
186
|
+
normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
|
|
187
|
+
end
|
|
188
|
+
|
|
37
189
|
def ensure_destination!
|
|
38
190
|
return unless JetstreamBridge.config.destination_app.to_s.empty?
|
|
39
191
|
|
|
@@ -55,9 +207,21 @@ module JetstreamBridge
|
|
|
55
207
|
"Publish ack error: #{ack.error}",
|
|
56
208
|
tag: 'JetstreamBridge::Publisher'
|
|
57
209
|
)
|
|
210
|
+
return Models::PublishResult.new(
|
|
211
|
+
success: false,
|
|
212
|
+
event_id: envelope['event_id'],
|
|
213
|
+
subject: subject,
|
|
214
|
+
error: StandardError.new(ack.error.to_s),
|
|
215
|
+
duplicate: duplicate
|
|
216
|
+
)
|
|
58
217
|
end
|
|
59
218
|
|
|
60
|
-
|
|
219
|
+
Models::PublishResult.new(
|
|
220
|
+
success: true,
|
|
221
|
+
event_id: envelope['event_id'],
|
|
222
|
+
subject: subject,
|
|
223
|
+
duplicate: duplicate
|
|
224
|
+
)
|
|
61
225
|
end
|
|
62
226
|
|
|
63
227
|
# ---- Outbox path ----
|
|
@@ -81,17 +245,31 @@ module JetstreamBridge
|
|
|
81
245
|
"Outbox already sent event_id=#{event_id}; skipping publish.",
|
|
82
246
|
tag: 'JetstreamBridge::Publisher'
|
|
83
247
|
)
|
|
84
|
-
return
|
|
248
|
+
return Models::PublishResult.new(
|
|
249
|
+
success: true,
|
|
250
|
+
event_id: event_id,
|
|
251
|
+
subject: subject,
|
|
252
|
+
duplicate: true
|
|
253
|
+
)
|
|
85
254
|
end
|
|
86
255
|
|
|
87
256
|
repo.persist_pre(record, subject, envelope)
|
|
88
257
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
258
|
+
result = with_retries { publish_to_nats(subject, envelope) }
|
|
259
|
+
if result.success?
|
|
260
|
+
repo.persist_success(record)
|
|
261
|
+
else
|
|
262
|
+
repo.persist_failure(record, result.error&.message || 'Publish failed')
|
|
263
|
+
end
|
|
264
|
+
result
|
|
92
265
|
rescue StandardError => e
|
|
93
266
|
repo.persist_exception(record, e) if defined?(repo) && defined?(record)
|
|
94
|
-
|
|
267
|
+
Models::PublishResult.new(
|
|
268
|
+
success: false,
|
|
269
|
+
event_id: envelope['event_id'],
|
|
270
|
+
subject: subject,
|
|
271
|
+
error: e
|
|
272
|
+
)
|
|
95
273
|
end
|
|
96
274
|
# ---- /Outbox path ----
|
|
97
275
|
|
|
@@ -99,15 +277,11 @@ module JetstreamBridge
|
|
|
99
277
|
def with_retries(&)
|
|
100
278
|
@retry_strategy.execute(context: 'Publisher', &)
|
|
101
279
|
rescue RetryStrategy::RetryExhausted => e
|
|
102
|
-
log_error(false, e)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def log_error(val, exc)
|
|
106
280
|
Logging.error(
|
|
107
|
-
"Publish failed: #{
|
|
281
|
+
"Publish failed after retries: #{e.class} #{e.message}",
|
|
108
282
|
tag: 'JetstreamBridge::Publisher'
|
|
109
283
|
)
|
|
110
|
-
|
|
284
|
+
raise
|
|
111
285
|
end
|
|
112
286
|
|
|
113
287
|
def build_envelope(resource_type, event_type, payload, options = {})
|
|
@@ -123,5 +297,50 @@ module JetstreamBridge
|
|
|
123
297
|
'payload' => payload
|
|
124
298
|
}
|
|
125
299
|
end
|
|
300
|
+
|
|
301
|
+
# Normalize a hash to match envelope structure, allowing partial envelopes
|
|
302
|
+
def normalize_envelope(hash, options = {})
|
|
303
|
+
hash = hash.transform_keys(&:to_s)
|
|
304
|
+
infer_resource_type_if_needed!(hash)
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
'event_id' => envelope_event_id(hash, options),
|
|
308
|
+
'schema_version' => hash['schema_version'] || 1,
|
|
309
|
+
'event_type' => hash['event_type'] || raise(ArgumentError, 'event_type is required'),
|
|
310
|
+
'producer' => hash['producer'] || JetstreamBridge.config.app_name,
|
|
311
|
+
'resource_id' => hash['resource_id'] || extract_resource_id(hash['payload']),
|
|
312
|
+
'occurred_at' => envelope_occurred_at(hash, options),
|
|
313
|
+
'trace_id' => envelope_trace_id(hash, options),
|
|
314
|
+
'resource_type' => hash['resource_type'] || 'event',
|
|
315
|
+
'payload' => hash['payload'] || raise(ArgumentError, 'payload is required')
|
|
316
|
+
}
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def infer_resource_type_if_needed!(hash)
|
|
320
|
+
return unless hash['event_type'] && hash['payload'] && !hash['resource_type']
|
|
321
|
+
|
|
322
|
+
# Try to infer from dot notation (e.g., 'user.created' -> 'user')
|
|
323
|
+
parts = hash['event_type'].split('.')
|
|
324
|
+
hash['resource_type'] = parts[0] if parts.size > 1
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def envelope_event_id(hash, options)
|
|
328
|
+
hash['event_id'] || options[:event_id] || SecureRandom.uuid
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def envelope_occurred_at(hash, options)
|
|
332
|
+
hash['occurred_at'] || (options[:occurred_at] || Time.now.utc).iso8601
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def envelope_trace_id(hash, options)
|
|
336
|
+
hash['trace_id'] || options[:trace_id] || SecureRandom.hex(8)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def extract_resource_id(payload)
|
|
340
|
+
return '' unless payload
|
|
341
|
+
|
|
342
|
+
payload = payload.transform_keys(&:to_s) if payload.respond_to?(:transform_keys)
|
|
343
|
+
(payload['id'] || payload[:id] || payload['resource_id'] || payload[:resource_id]).to_s
|
|
344
|
+
end
|
|
126
345
|
end
|
|
127
346
|
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module JetstreamBridge
|
|
6
|
+
# Test helpers for easier testing of JetStream Bridge integrations
|
|
7
|
+
#
|
|
8
|
+
# @example RSpec configuration
|
|
9
|
+
# require 'jetstream_bridge/test_helpers'
|
|
10
|
+
#
|
|
11
|
+
# RSpec.configure do |config|
|
|
12
|
+
# config.include JetstreamBridge::TestHelpers
|
|
13
|
+
#
|
|
14
|
+
# config.before(:each, :jetstream) do
|
|
15
|
+
# JetstreamBridge::TestHelpers.enable_test_mode!
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# config.after(:each, :jetstream) do
|
|
19
|
+
# JetstreamBridge::TestHelpers.reset_test_mode!
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example Using in tests
|
|
24
|
+
# RSpec.describe UserService, :jetstream do
|
|
25
|
+
# it "publishes user created event" do
|
|
26
|
+
# service.create_user(name: "Ada")
|
|
27
|
+
#
|
|
28
|
+
# expect(JetstreamBridge).to have_published(
|
|
29
|
+
# event_type: "user.created",
|
|
30
|
+
# payload: hash_including(name: "Ada")
|
|
31
|
+
# )
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
module TestHelpers
|
|
36
|
+
class << self
|
|
37
|
+
# Enable test mode with in-memory event capture
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
def enable_test_mode!
|
|
41
|
+
@test_mode = true
|
|
42
|
+
@published_events = []
|
|
43
|
+
@consumed_events = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reset test mode and clear captured events
|
|
47
|
+
#
|
|
48
|
+
# @return [void]
|
|
49
|
+
def reset_test_mode!
|
|
50
|
+
@test_mode = false
|
|
51
|
+
@published_events = []
|
|
52
|
+
@consumed_events = []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if test mode is enabled
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
def test_mode?
|
|
59
|
+
@test_mode ||= false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get all published events captured in test mode
|
|
63
|
+
#
|
|
64
|
+
# @return [Array<Hash>] Array of published event hashes
|
|
65
|
+
def published_events
|
|
66
|
+
@published_events ||= []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get all consumed events captured in test mode
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<Hash>] Array of consumed event hashes
|
|
72
|
+
def consumed_events
|
|
73
|
+
@consumed_events ||= []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Record a published event (called internally)
|
|
77
|
+
#
|
|
78
|
+
# @param event [Hash] Event data
|
|
79
|
+
# @return [void]
|
|
80
|
+
def record_published_event(event)
|
|
81
|
+
@published_events ||= []
|
|
82
|
+
@published_events << event.dup
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Record a consumed event (called internally)
|
|
86
|
+
#
|
|
87
|
+
# @param event [Hash] Event data
|
|
88
|
+
# @return [void]
|
|
89
|
+
def record_consumed_event(event)
|
|
90
|
+
@consumed_events ||= []
|
|
91
|
+
@consumed_events << event.dup
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Build a test Event object
|
|
96
|
+
#
|
|
97
|
+
# @param event_type [String] Event type (e.g., "user.created")
|
|
98
|
+
# @param payload [Hash] Event payload
|
|
99
|
+
# @param event_id [String, nil] Optional event ID
|
|
100
|
+
# @param trace_id [String, nil] Optional trace ID
|
|
101
|
+
# @param occurred_at [Time, String, nil] Optional timestamp
|
|
102
|
+
# @param metadata [Hash] Optional metadata
|
|
103
|
+
# @return [Models::Event] Event object
|
|
104
|
+
#
|
|
105
|
+
# @example
|
|
106
|
+
# event = build_jetstream_event(
|
|
107
|
+
# event_type: "user.created",
|
|
108
|
+
# payload: { id: 1, email: "user@example.com" }
|
|
109
|
+
# )
|
|
110
|
+
# handler.call(event)
|
|
111
|
+
#
|
|
112
|
+
def build_jetstream_event(event_type:, payload:, event_id: nil, trace_id: nil, occurred_at: nil, **metadata)
|
|
113
|
+
event_hash = {
|
|
114
|
+
'event_id' => event_id || SecureRandom.uuid,
|
|
115
|
+
'schema_version' => 1,
|
|
116
|
+
'event_type' => event_type,
|
|
117
|
+
'producer' => 'test',
|
|
118
|
+
'resource_id' => (payload['id'] || payload[:id] || '').to_s,
|
|
119
|
+
'occurred_at' => (occurred_at || Time.now.utc).iso8601,
|
|
120
|
+
'trace_id' => trace_id || SecureRandom.hex(8),
|
|
121
|
+
'resource_type' => event_type.split('.').first || 'event',
|
|
122
|
+
'payload' => payload
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
Models::Event.new(
|
|
126
|
+
event_hash,
|
|
127
|
+
metadata: {
|
|
128
|
+
subject: metadata[:subject] || 'test.subject',
|
|
129
|
+
deliveries: metadata[:deliveries] || 1,
|
|
130
|
+
stream: metadata[:stream] || 'test-stream',
|
|
131
|
+
sequence: metadata[:sequence] || 1,
|
|
132
|
+
consumer: metadata[:consumer] || 'test-consumer',
|
|
133
|
+
timestamp: Time.now
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Simulate triggering an event to a consumer
|
|
139
|
+
#
|
|
140
|
+
# @param event [Models::Event, Hash] Event to trigger
|
|
141
|
+
# @param handler [Proc, #call] Handler to call with event
|
|
142
|
+
# @return [void]
|
|
143
|
+
#
|
|
144
|
+
# @example
|
|
145
|
+
# event = build_jetstream_event(event_type: "user.created", payload: { id: 1 })
|
|
146
|
+
# trigger_jetstream_event(event, ->(e) { process_event(e) })
|
|
147
|
+
#
|
|
148
|
+
def trigger_jetstream_event(event, handler = nil)
|
|
149
|
+
handler ||= @handler if defined?(@handler)
|
|
150
|
+
raise ArgumentError, 'handler is required' unless handler
|
|
151
|
+
|
|
152
|
+
TestHelpers.record_consumed_event(event.to_h) if TestHelpers.test_mode?
|
|
153
|
+
handler.call(event)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# RSpec matchers module
|
|
157
|
+
#
|
|
158
|
+
# @example Include in RSpec
|
|
159
|
+
# RSpec.configure do |config|
|
|
160
|
+
# config.include JetstreamBridge::TestHelpers
|
|
161
|
+
# config.include JetstreamBridge::TestHelpers::Matchers
|
|
162
|
+
# end
|
|
163
|
+
#
|
|
164
|
+
module Matchers
|
|
165
|
+
# Matcher for checking if an event was published
|
|
166
|
+
#
|
|
167
|
+
# @param event_type [String] Event type to match
|
|
168
|
+
# @param payload [Hash] Optional payload attributes to match
|
|
169
|
+
# @return [HavePublished] Matcher instance
|
|
170
|
+
#
|
|
171
|
+
# @example
|
|
172
|
+
# expect(JetstreamBridge).to have_published(
|
|
173
|
+
# event_type: "user.created",
|
|
174
|
+
# payload: { id: 1 }
|
|
175
|
+
# )
|
|
176
|
+
#
|
|
177
|
+
def have_published(event_type:, payload: {})
|
|
178
|
+
HavePublished.new(event_type, payload)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Matcher implementation for have_published
|
|
182
|
+
class HavePublished
|
|
183
|
+
def initialize(event_type, payload_attributes)
|
|
184
|
+
@event_type = event_type
|
|
185
|
+
@payload_attributes = payload_attributes
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def matches?(_actual)
|
|
189
|
+
TestHelpers.published_events.any? do |event|
|
|
190
|
+
matches_event_type?(event) && matches_payload?(event)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def failure_message
|
|
195
|
+
"expected to have published event_type: #{@event_type.inspect} " \
|
|
196
|
+
"with payload: #{@payload_attributes.inspect}\n" \
|
|
197
|
+
"but found events: #{TestHelpers.published_events.map { |e| e['event_type'] }.inspect}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def failure_message_when_negated
|
|
201
|
+
"expected not to have published event_type: #{@event_type.inspect} " \
|
|
202
|
+
"with payload: #{@payload_attributes.inspect}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def matches_event_type?(event)
|
|
208
|
+
event['event_type'] == @event_type || event[:event_type] == @event_type
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def matches_payload?(event)
|
|
212
|
+
payload = event['payload'] || event[:payload] || {}
|
|
213
|
+
@payload_attributes.all? do |key, value|
|
|
214
|
+
payload_value = payload[key.to_s] || payload[key.to_sym]
|
|
215
|
+
if value.is_a?(RSpec::Matchers::BuiltIn::BaseMatcher)
|
|
216
|
+
value.matches?(payload)
|
|
217
|
+
else
|
|
218
|
+
payload_value == value
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Matcher for checking publish result
|
|
225
|
+
#
|
|
226
|
+
# @example
|
|
227
|
+
# result = JetstreamBridge.publish(...)
|
|
228
|
+
# expect(result).to be_publish_success
|
|
229
|
+
#
|
|
230
|
+
def be_publish_success
|
|
231
|
+
BePublishSuccess.new
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Matcher implementation for be_publish_success
|
|
235
|
+
class BePublishSuccess
|
|
236
|
+
def matches?(actual)
|
|
237
|
+
actual.respond_to?(:success?) && actual.success?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def failure_message
|
|
241
|
+
'expected PublishResult to be successful but it failed'
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def failure_message_when_negated
|
|
245
|
+
'expected PublishResult to not be successful but it was'
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Matcher for checking publish failure
|
|
250
|
+
#
|
|
251
|
+
# @example
|
|
252
|
+
# result = JetstreamBridge.publish(...)
|
|
253
|
+
# expect(result).to be_publish_failure
|
|
254
|
+
#
|
|
255
|
+
def be_publish_failure
|
|
256
|
+
BePublishFailure.new
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Matcher implementation for be_publish_failure
|
|
260
|
+
class BePublishFailure
|
|
261
|
+
def matches?(actual)
|
|
262
|
+
actual.respond_to?(:failure?) && actual.failure?
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def failure_message
|
|
266
|
+
'expected PublishResult to be a failure but it succeeded'
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def failure_message_when_negated
|
|
270
|
+
'expected PublishResult to not be a failure but it was'
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|