bug_bunny 4.13.0 → 4.16.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.
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/integration_helper'
5
+
6
+ # Specs de integración para Publisher Confirms en modo :confirmed + mandatory.
7
+ # Verifican el flow end-to-end del bridge `basic.return` → `PublishUnroutable`
8
+ # contra un RabbitMQ real.
9
+ #
10
+ # Se skippean automáticamente si el broker no está disponible (ver
11
+ # `spec_helper.rb` → `before(:each, :integration)`).
12
+ RSpec.describe 'Publisher Confirms — return_raise', :integration do
13
+ let(:client) { BugBunny::Client.new(pool: TEST_POOL) }
14
+
15
+ # Exchange unbound: existe pero ninguna cola está bindeada a él.
16
+ # Cualquier publish con mandatory:true sobre este exchange retornará.
17
+ let(:unbound_exchange) { unique('unroutable_x') }
18
+ # Exchange con cola bindeada: publish con mandatory:true llega bien.
19
+ let(:routable_exchange) { unique('routable_x') }
20
+
21
+ # Declara el exchange sin bindings para asegurar que `basic.return` se dispare.
22
+ # Usa una conexión fresca para no contaminar el pool.
23
+ def declare_unbound_exchange!(name)
24
+ conn = BugBunny.create_connection
25
+ ch = conn.create_channel
26
+ ch.topic(name, BugBunny.configuration.exchange_options)
27
+ ch.close
28
+ conn.close
29
+ end
30
+
31
+ before do
32
+ declare_unbound_exchange!(unbound_exchange)
33
+ # Reset flag a default conocido por si algún spec previo lo cambió.
34
+ BugBunny.configuration.return_raise = true
35
+ BugBunny.configuration.on_return = nil
36
+ end
37
+
38
+ after do
39
+ BugBunny.configuration.return_raise = true
40
+ BugBunny.configuration.on_return = nil
41
+ end
42
+
43
+ describe 'mandatory: true sobre exchange sin bindings' do
44
+ it 'levanta BugBunny::PublishUnroutable por default (return_raise=true)' do
45
+ expect {
46
+ client.publish('acct.unbound',
47
+ exchange: unbound_exchange,
48
+ exchange_type: 'topic',
49
+ confirmed: true,
50
+ mandatory: true,
51
+ body: { tenant: 42 })
52
+ }.to raise_error(BugBunny::PublishUnroutable) do |err|
53
+ expect(err.path).to eq('acct.unbound')
54
+ expect(err.exchange).to eq(unbound_exchange)
55
+ expect(err.routing_key).to eq('acct.unbound')
56
+ expect(err.reply_code).to eq(312)
57
+ expect(err.reply_text).to match(/NO_ROUTE/i)
58
+ expect(err.correlation_id).to be_a(String)
59
+ expect(err.correlation_id).not_to be_empty
60
+ end
61
+ end
62
+
63
+ it 'NO levanta si el request override `return_raise: false`' do
64
+ result = client.publish('acct.unbound',
65
+ exchange: unbound_exchange,
66
+ exchange_type: 'topic',
67
+ confirmed: true,
68
+ mandatory: true,
69
+ return_raise: false,
70
+ body: { tenant: 42 })
71
+
72
+ expect(result).to eq('status' => 202, 'body' => nil)
73
+ end
74
+
75
+ it 'NO levanta si la config global tiene `return_raise = false`' do
76
+ BugBunny.configuration.return_raise = false
77
+
78
+ result = client.publish('acct.unbound',
79
+ exchange: unbound_exchange,
80
+ exchange_type: 'topic',
81
+ confirmed: true,
82
+ mandatory: true,
83
+ body: { tenant: 42 })
84
+
85
+ expect(result).to eq('status' => 202, 'body' => nil)
86
+ end
87
+
88
+ it 'invoca el callback global on_return antes de levantar' do
89
+ captured = nil
90
+ BugBunny.configuration.on_return = lambda { |return_info, _props, _body|
91
+ captured = { exchange: return_info.exchange, rk: return_info.routing_key }
92
+ }
93
+
94
+ expect {
95
+ client.publish('acct.unbound',
96
+ exchange: unbound_exchange,
97
+ exchange_type: 'topic',
98
+ confirmed: true,
99
+ mandatory: true,
100
+ body: { tenant: 42 })
101
+ }.to raise_error(BugBunny::PublishUnroutable)
102
+
103
+ expect(captured).not_to be_nil
104
+ expect(captured[:exchange]).to eq(unbound_exchange)
105
+ expect(captured[:rk]).to eq('acct.unbound')
106
+ end
107
+
108
+ it 'levanta igual cuando el user_cb on_return explota' do
109
+ BugBunny.configuration.on_return = ->(_, _, _) { raise 'boom in user cb' }
110
+
111
+ expect {
112
+ client.publish('acct.unbound',
113
+ exchange: unbound_exchange,
114
+ exchange_type: 'topic',
115
+ confirmed: true,
116
+ mandatory: true,
117
+ body: { tenant: 42 })
118
+ }.to raise_error(BugBunny::PublishUnroutable)
119
+ end
120
+
121
+ it 'override per-request gana sobre config global = false' do
122
+ BugBunny.configuration.return_raise = false
123
+
124
+ expect {
125
+ client.publish('acct.unbound',
126
+ exchange: unbound_exchange,
127
+ exchange_type: 'topic',
128
+ confirmed: true,
129
+ mandatory: true,
130
+ return_raise: true,
131
+ body: { tenant: 42 })
132
+ }.to raise_error(BugBunny::PublishUnroutable)
133
+ end
134
+ end
135
+
136
+ describe 'mandatory: true sobre exchange con binding (happy path)' do
137
+ # Declara queue exclusive + binding contra `routable_exchange` para que el
138
+ # publish sea ruteable. Exclusive evita la deprecación de transient_nonexcl_queues
139
+ # en versiones modernas de RabbitMQ.
140
+ def with_exclusive_binding(exchange:, routing_key:)
141
+ conn = BugBunny.create_connection
142
+ ch = conn.create_channel
143
+ x = ch.topic(exchange, BugBunny.configuration.exchange_options)
144
+ q = ch.queue('', exclusive: true, auto_delete: true)
145
+ q.bind(x, routing_key: routing_key)
146
+ yield
147
+ ensure
148
+ ch&.close
149
+ conn&.close
150
+ end
151
+
152
+ it 'retorna 202 sin levantar — el mensaje rutea normal' do
153
+ with_exclusive_binding(exchange: routable_exchange, routing_key: 'acct.#') do
154
+ result = client.publish('acct.start',
155
+ exchange: routable_exchange,
156
+ exchange_type: 'topic',
157
+ confirmed: true,
158
+ mandatory: true,
159
+ body: { tenant: 99 })
160
+
161
+ expect(result).to eq('status' => 202, 'body' => nil)
162
+ end
163
+ end
164
+ end
165
+
166
+ describe 'mandatory: false (flag inerte)' do
167
+ it 'no levanta aunque return_raise=true y la routing key no rutee a ninguna cola' do
168
+ result = client.publish('acct.unbound',
169
+ exchange: unbound_exchange,
170
+ exchange_type: 'topic',
171
+ confirmed: true,
172
+ mandatory: false,
173
+ return_raise: true,
174
+ body: { tenant: 1 })
175
+
176
+ expect(result).to eq('status' => 202, 'body' => nil)
177
+ end
178
+ end
179
+
180
+ describe 'concurrencia multi-thread sobre el mismo client' do
181
+ # Verifica que la correlación por correlation_id aísla los outcomes:
182
+ # N threads publican simultáneamente sobre el mismo exchange unbound, cada uno
183
+ # debe recibir SU propio PublishUnroutable (no el de otro thread).
184
+ it 'cada caller recibe su propio raise sin contaminación cross-thread' do
185
+ threads = 8
186
+ results = Concurrent::Array.new
187
+
188
+ pool = Array.new(threads) do |i|
189
+ Thread.new do
190
+ rk = "thread.#{i}.unbound"
191
+ client.publish(rk,
192
+ exchange: unbound_exchange,
193
+ exchange_type: 'topic',
194
+ confirmed: true,
195
+ mandatory: true,
196
+ body: { tid: i })
197
+ results << { tid: i, raised: false }
198
+ rescue BugBunny::PublishUnroutable => e
199
+ results << { tid: i, raised: true, rk: e.routing_key, cid: e.correlation_id }
200
+ end
201
+ end
202
+
203
+ pool.each(&:join)
204
+
205
+ expect(results.size).to eq(threads)
206
+ expect(results.all? { |r| r[:raised] }).to be(true), 'todos deberían haber raised'
207
+ expect(results.map { |r| r[:rk] }.sort).to eq((0...threads).map { |i| "thread.#{i}.unbound" }.sort)
208
+ # Todos los correlation_ids deben ser distintos (no hubo cross-thread leakage)
209
+ cids = results.map { |r| r[:cid] }
210
+ expect(cids.uniq.size).to eq(threads)
211
+ end
212
+ end
213
+
214
+ describe 'aislamiento entre exchanges sobre el mismo channel' do
215
+ # Publish A sobre exchange unbound (debe raisear) y publish B sobre exchange routable
216
+ # (debe pasar). Validamos que el return de A no contamina B.
217
+ it 'return en exchange A no afecta publish concurrente a exchange B' do
218
+ bound_ex = unique('bound_b_x')
219
+ bound_q = unique('bound_b_q')
220
+
221
+ # Setup: exchange routable con queue exclusive bindeada
222
+ conn = BugBunny.create_connection
223
+ ch = conn.create_channel
224
+ x = ch.topic(bound_ex, BugBunny.configuration.exchange_options)
225
+ q = ch.queue('', exclusive: true, auto_delete: true)
226
+ q.bind(x, routing_key: '#')
227
+
228
+ results = Concurrent::Array.new
229
+
230
+ t_a = Thread.new do
231
+ client.publish('a.unbound',
232
+ exchange: unbound_exchange,
233
+ exchange_type: 'topic',
234
+ confirmed: true,
235
+ mandatory: true,
236
+ body: { side: 'A' })
237
+ results << { side: 'A', raised: false }
238
+ rescue BugBunny::PublishUnroutable
239
+ results << { side: 'A', raised: true }
240
+ end
241
+
242
+ t_b = Thread.new do
243
+ client.publish('b.routable',
244
+ exchange: bound_ex,
245
+ exchange_type: 'topic',
246
+ confirmed: true,
247
+ mandatory: true,
248
+ body: { side: 'B' })
249
+ results << { side: 'B', raised: false }
250
+ rescue BugBunny::PublishUnroutable
251
+ results << { side: 'B', raised: true }
252
+ end
253
+
254
+ [t_a, t_b].each(&:join)
255
+
256
+ a = results.find { |r| r[:side] == 'A' }
257
+ b = results.find { |r| r[:side] == 'B' }
258
+ expect(a[:raised]).to be(true), 'A (unbound) debería haber raised'
259
+ expect(b[:raised]).to be(false), 'B (routable) NO debería haber raised'
260
+ ensure
261
+ ch&.close
262
+ conn&.close
263
+ end
264
+ end
265
+
266
+ describe 'no hay leak en el registry tras publishes seriales' do
267
+ # 30 publishes seriales (mix routable + unroutable). Tras todos, el registry
268
+ # @pending_returns debe estar en 0. Detecta entries colgadas por cleanup mal hecho.
269
+ it 'registry vuelve a 0 tras una serie de publishes' do
270
+ 30.times do |i|
271
+ target = i.even? ? unbound_exchange : nil
272
+ if target
273
+ begin
274
+ client.publish("serial.#{i}",
275
+ exchange: target,
276
+ exchange_type: 'topic',
277
+ confirmed: true,
278
+ mandatory: true,
279
+ body: { i: i })
280
+ rescue BugBunny::PublishUnroutable
281
+ # esperado en routes no-ruteables
282
+ end
283
+ else
284
+ # publish sin mandatory para no triggerear return — no-op para el registry
285
+ client.publish("serial.#{i}",
286
+ exchange: unbound_exchange,
287
+ exchange_type: 'topic',
288
+ confirmed: true,
289
+ mandatory: false,
290
+ body: { i: i })
291
+ end
292
+ end
293
+
294
+ # Inspeccionar cada Session del pool — el registry debe estar vacío en todas.
295
+ total_pending = 0
296
+ TEST_POOL.with do |conn|
297
+ session = conn.instance_variable_get(:@_bug_bunny_session)
298
+ registry = session.instance_variable_get(:@pending_returns)
299
+ total_pending += registry.size
300
+ end
301
+ expect(total_pending).to eq(0)
302
+ end
303
+ end
304
+ end
data/spec/spec_helper.rb CHANGED
@@ -23,8 +23,11 @@ BugBunny.configure do |config|
23
23
  config.password = ENV.fetch('RABBITMQ_PASS', 'guest')
24
24
  config.vhost = '/'
25
25
  config.logger = Logger.new($stdout).tap { |l| l.level = Logger::WARN }
26
+ # exchange_options: explícito para que los specs declaren exchanges efímeros.
27
+ # queue_options: NO override — confiamos en `DEFAULT_QUEUE_OPTIONS` (durable shared,
28
+ # válido en RMQ 3.x y 4.x). Specs que necesitan colas efímeras usan
29
+ # `TEST_WORKER_QUEUE_OPTS` desde `spec/support/integration_helper.rb`.
26
30
  config.exchange_options = { durable: false, auto_delete: true }
27
- config.queue_options = { exclusive: false, durable: false, auto_delete: true }
28
31
  end
29
32
 
30
33
  TEST_POOL ||= ConnectionPool.new(size: 5, timeout: 5) { BugBunny.create_connection }
@@ -2,6 +2,12 @@
2
2
 
3
3
  require 'timeout'
4
4
 
5
+ # Queue options usados por los workers de integración. \`exclusive: true\` evita
6
+ # la deprecación de \`transient_nonexcl_queues\` en RabbitMQ 4.x — la queue queda
7
+ # ligada a la conexión del worker y desaparece automáticamente al cerrarla.
8
+ # Sobreescribe la cascada de \`BugBunny.configuration.queue_options\`.
9
+ TEST_WORKER_QUEUE_OPTS = { exclusive: true, durable: false, auto_delete: true }.freeze unless defined?(TEST_WORKER_QUEUE_OPTS)
10
+
5
11
  # Helpers compartidos para specs de integración con RabbitMQ real.
6
12
  # Incluido automáticamente en todos los specs marcados con :integration.
7
13
  RSpec.shared_context 'integration helpers' do
@@ -31,6 +37,7 @@ RSpec.shared_context 'integration helpers' do
31
37
  exchange_name: exchange,
32
38
  exchange_type: exchange_type,
33
39
  routing_key: routing_key,
40
+ queue_opts: TEST_WORKER_QUEUE_OPTS,
34
41
  block: true
35
42
  )
36
43
  rescue StandardError => e
@@ -57,7 +64,7 @@ RSpec.shared_context 'integration helpers' do
57
64
  worker_thread = Thread.new do
58
65
  ch = conn.create_channel
59
66
  x = ch.public_send(exchange_type, exchange, BugBunny.configuration.exchange_options)
60
- q = ch.queue(queue, BugBunny.configuration.queue_options)
67
+ q = ch.queue(queue, TEST_WORKER_QUEUE_OPTS)
61
68
  q.bind(x, routing_key: routing_key)
62
69
  q.subscribe(block: true) do |delivery, props, body|
63
70
  messages << { body: body, routing_key: delivery.routing_key, headers: props.headers }
@@ -215,6 +215,78 @@ RSpec.describe BugBunny::Client, 'session pooling' do
215
215
  end
216
216
  end
217
217
 
218
+ describe 'warn_return_raise_misuse' do
219
+ let(:log_io) { StringIO.new }
220
+
221
+ before do
222
+ @prev_logger = BugBunny.configuration.logger
223
+ BugBunny.configuration.logger = Logger.new(log_io).tap { |l| l.level = Logger::WARN }
224
+ end
225
+
226
+ after do
227
+ BugBunny.configuration.logger = @prev_logger
228
+ end
229
+
230
+ def stub_producer_to_noop
231
+ allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) { { 'status' => 202, 'body' => nil } }
232
+ allow_any_instance_of(BugBunny::Producer).to receive(:fire) { { 'status' => 202, 'body' => nil } }
233
+ end
234
+
235
+ it 'logea warning cuando return_raise:true se pasa sin confirmed' do
236
+ stub_producer_to_noop
237
+ client = described_class.new(pool: fake_pool(fake_conn))
238
+
239
+ client.publish('foo', exchange: 'x', exchange_type: 'direct',
240
+ return_raise: true, mandatory: true)
241
+
242
+ expect(log_io.string).to include('event=client.return_raise_ignored')
243
+ expect(log_io.string).to include('delivery_mode=publish')
244
+ end
245
+
246
+ it 'logea warning cuando return_raise:true se pasa sin mandatory' do
247
+ stub_producer_to_noop
248
+ client = described_class.new(pool: fake_pool(fake_conn))
249
+
250
+ client.publish('foo', exchange: 'x', exchange_type: 'direct',
251
+ return_raise: true, confirmed: true)
252
+
253
+ expect(log_io.string).to include('event=client.return_raise_ignored')
254
+ expect(log_io.string).to include('mandatory=false')
255
+ end
256
+
257
+ it 'NO logea warning cuando confirmed+mandatory se setean via block API' do
258
+ stub_producer_to_noop
259
+ client = described_class.new(pool: fake_pool(fake_conn))
260
+
261
+ client.publish('foo', exchange: 'x', exchange_type: 'direct', return_raise: true) do |req|
262
+ req.delivery_mode = :confirmed
263
+ req.mandatory = true
264
+ end
265
+
266
+ expect(log_io.string).not_to include('client.return_raise_ignored')
267
+ end
268
+
269
+ it 'NO logea warning cuando return_raise no fue seteado per-request (deja el default global)' do
270
+ stub_producer_to_noop
271
+ client = described_class.new(pool: fake_pool(fake_conn))
272
+
273
+ # Default global es true, pero el caller no fue explícito → no warneamos
274
+ client.publish('foo', exchange: 'x', exchange_type: 'direct')
275
+
276
+ expect(log_io.string).not_to include('client.return_raise_ignored')
277
+ end
278
+
279
+ it 'NO logea warning cuando confirmed+mandatory+return_raise:true coexisten' do
280
+ stub_producer_to_noop
281
+ client = described_class.new(pool: fake_pool(fake_conn))
282
+
283
+ client.publish('foo', exchange: 'x', exchange_type: 'direct',
284
+ confirmed: true, mandatory: true, return_raise: true)
285
+
286
+ expect(log_io.string).not_to include('client.return_raise_ignored')
287
+ end
288
+ end
289
+
218
290
  describe 'Session no se cierra entre requests' do
219
291
  it 'no invoca close en la Session al terminar el request' do
220
292
  conn = fake_conn
@@ -174,6 +174,18 @@ RSpec.describe BugBunny::Configuration do
174
174
  end
175
175
  end
176
176
 
177
+ describe 'return_raise flag' do
178
+ it 'tiene default true (raise PublishUnroutable en basic.return)' do
179
+ expect(BugBunny::Configuration.new.return_raise).to be(true)
180
+ end
181
+
182
+ it 'acepta false para opt-out (modo legacy)' do
183
+ configure_with(return_raise: false)
184
+
185
+ expect(BugBunny.configuration.return_raise).to be(false)
186
+ end
187
+ end
188
+
177
189
  describe '.validate! directamente' do
178
190
  it 'es invocable directamente sobre la instancia' do
179
191
  config = BugBunny::Configuration.new