bug_bunny 4.14.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.
@@ -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
@@ -248,6 +248,12 @@ RSpec.describe BugBunny::Producer do
248
248
  s = instance_double(BugBunny::Session)
249
249
  allow(s).to receive(:exchange).and_return(fake_exchange)
250
250
  allow(s).to receive(:channel).and_return(mock_channel)
251
+ # Return registry stubs: tests que activan `mandatory: true` con `return_raise`
252
+ # default invocan estos hooks. Devolvemos un event/slot vacío (no return).
253
+ allow(s).to receive(:register_return_listener) do |_cid|
254
+ [Concurrent::Event.new, { event: Concurrent::Event.new, info: nil }]
255
+ end
256
+ allow(s).to receive(:unregister_return_listener)
251
257
  s
252
258
  end
253
259
 
@@ -384,5 +390,206 @@ RSpec.describe BugBunny::Producer do
384
390
  expect { confirmed_producer.confirmed(build_request) }
385
391
  .to raise_error(BugBunny::CommunicationError, 'chan dead')
386
392
  end
393
+
394
+ describe 'return_raise (basic.return + mandatory)' do
395
+ # Dispara el `basic.return` desde el reader-thread simulado. El stub de
396
+ # wait_for_confirms invoca este lambda *antes* de retornar true, simulando
397
+ # el orden AMQP wire: return precede al ack.
398
+ def stub_return_during_wait(producer, return_info)
399
+ allow(mock_channel).to receive(:wait_for_confirms) do
400
+ props = double('properties', correlation_id: producer_pending_cid(producer))
401
+ producer.instance_variable_get(:@session)
402
+ .send(:handle_broker_return, return_info, props, 'payload')
403
+ true
404
+ end
405
+ end
406
+
407
+ def producer_pending_cid(producer)
408
+ session = producer.instance_variable_get(:@session)
409
+ registry = session.instance_variable_get(:@pending_returns)
410
+ cids = []
411
+ registry.each_pair { |cid, _slot| cids << cid }
412
+ cids.first
413
+ end
414
+
415
+ let(:return_info) do
416
+ Struct.new(:reply_code, :reply_text, :exchange, :routing_key)
417
+ .new(312, 'NO_ROUTE', 'acct_x', 'acct.unbound')
418
+ end
419
+
420
+ let(:real_session) do
421
+ s = BugBunny::Session.new(BunnyMocks::FakeConnection.new(true, mock_channel))
422
+ allow(s).to receive(:channel).and_return(mock_channel)
423
+ allow(s).to receive(:exchange).and_return(fake_exchange)
424
+ s
425
+ end
426
+
427
+ let(:return_producer) do
428
+ p = described_class.new(real_session)
429
+ allow(p).to receive(:safe_log) do |level, event, **kwargs|
430
+ logged_events << { level: level, event: event, kwargs: kwargs }
431
+ end
432
+ p
433
+ end
434
+
435
+ before do
436
+ BugBunny.configuration.on_return = nil
437
+ end
438
+
439
+ after do
440
+ BugBunny.configuration.on_return = nil
441
+ end
442
+
443
+ def build_mandatory_request
444
+ req = BugBunny::Request.new('acct.start')
445
+ req.exchange = 'acct_x'
446
+ req.method = :post
447
+ req.body = { tenant: 42 }
448
+ req.mandatory = true
449
+ req
450
+ end
451
+
452
+ it 'levanta PublishUnroutable cuando llega basic.return + ack y mandatory:true (default)' do
453
+ stub_return_during_wait(return_producer, return_info)
454
+
455
+ req = build_mandatory_request
456
+
457
+ expect { return_producer.confirmed(req) }.to raise_error(BugBunny::PublishUnroutable) do |err|
458
+ expect(err.path).to eq('acct.start')
459
+ expect(err.exchange).to eq('acct_x')
460
+ expect(err.routing_key).to eq('acct.unbound')
461
+ expect(err.reply_code).to eq(312)
462
+ expect(err.reply_text).to eq('NO_ROUTE')
463
+ expect(err.correlation_id).to eq(req.correlation_id)
464
+ end
465
+ end
466
+
467
+ it 'logea producer.publish_unroutable antes de levantar' do
468
+ stub_return_during_wait(return_producer, return_info)
469
+
470
+ expect { return_producer.confirmed(build_mandatory_request) }
471
+ .to raise_error(BugBunny::PublishUnroutable)
472
+
473
+ ev = logged_events.find { |e| e[:event] == 'producer.publish_unroutable' }
474
+ expect(ev).not_to be_nil
475
+ expect(ev[:level]).to eq(:warn)
476
+ expect(ev[:kwargs]).to include(
477
+ path: 'acct.start',
478
+ exchange: 'acct_x',
479
+ routing_key: 'acct.unbound',
480
+ reply_code: 312
481
+ )
482
+ end
483
+
484
+ it 'auto-asigna correlation_id cuando falta y return_raise está activo' do
485
+ stub_return_during_wait(return_producer, return_info)
486
+ req = build_mandatory_request
487
+ expect(req.correlation_id).to be_nil
488
+
489
+ expect { return_producer.confirmed(req) }.to raise_error(BugBunny::PublishUnroutable)
490
+ expect(req.correlation_id).to be_a(String)
491
+ expect(req.correlation_id).not_to be_empty
492
+ end
493
+
494
+ it 'limpia el listener del registry tras un return (no leak)' do
495
+ stub_return_during_wait(return_producer, return_info)
496
+ req = build_mandatory_request
497
+
498
+ expect { return_producer.confirmed(req) }.to raise_error(BugBunny::PublishUnroutable)
499
+
500
+ registry = real_session.instance_variable_get(:@pending_returns)
501
+ expect(registry.size).to eq(0)
502
+ end
503
+
504
+ it 'limpia el listener del registry tras un ack normal sin return' do
505
+ # mock_channel.wait_for_confirms default ya devuelve true sin disparar return
506
+ req = build_mandatory_request
507
+ result = return_producer.confirmed(req)
508
+
509
+ expect(result).to eq('status' => 202, 'body' => nil)
510
+ registry = real_session.instance_variable_get(:@pending_returns)
511
+ expect(registry.size).to eq(0)
512
+ end
513
+
514
+ it 'limpia el listener del registry tras timeout en wait_for_confirms' do
515
+ allow(mock_channel).to receive(:wait_for_confirms) {
516
+ sleep 1
517
+ true
518
+ }
519
+
520
+ req = build_mandatory_request
521
+ req.confirm_timeout = 0.05
522
+
523
+ expect { return_producer.confirmed(req) }.to raise_error(BugBunny::RequestTimeout)
524
+ registry = real_session.instance_variable_get(:@pending_returns)
525
+ expect(registry.size).to eq(0)
526
+ end
527
+
528
+ it 'no levanta cuando el request override `return_raise: false`' do
529
+ # No stub de return — el listener no se registra siquiera.
530
+ req = build_mandatory_request
531
+ req.return_raise = false
532
+
533
+ result = return_producer.confirmed(req)
534
+ expect(result).to eq('status' => 202, 'body' => nil)
535
+ # Sin listener registrado (flag off) → no hubo set up
536
+ registry = real_session.instance_variable_get(:@pending_returns)
537
+ expect(registry.size).to eq(0)
538
+ end
539
+
540
+ it 'no levanta cuando la config global tiene return_raise=false (aunque mandatory esté on)' do
541
+ allow(BugBunny.configuration).to receive(:return_raise).and_return(false)
542
+
543
+ req = build_mandatory_request
544
+ result = return_producer.confirmed(req)
545
+
546
+ expect(result).to eq('status' => 202, 'body' => nil)
547
+ end
548
+
549
+ it 'override per-request gana sobre la config global' do
550
+ allow(BugBunny.configuration).to receive(:return_raise).and_return(false)
551
+ stub_return_during_wait(return_producer, return_info)
552
+
553
+ req = build_mandatory_request
554
+ req.return_raise = true
555
+
556
+ expect { return_producer.confirmed(req) }.to raise_error(BugBunny::PublishUnroutable)
557
+ end
558
+
559
+ it 'flag inerte cuando mandatory:false aunque return_raise=true' do
560
+ req = BugBunny::Request.new('acct.start')
561
+ req.exchange = 'acct_x'
562
+ req.method = :post
563
+ req.body = { tenant: 42 }
564
+ req.mandatory = false
565
+ req.return_raise = true
566
+
567
+ result = return_producer.confirmed(req)
568
+ expect(result).to eq('status' => 202, 'body' => nil)
569
+ registry = real_session.instance_variable_get(:@pending_returns)
570
+ expect(registry.size).to eq(0)
571
+ end
572
+
573
+ it 'invoca el callback global on_return antes de levantar PublishUnroutable' do
574
+ captured = nil
575
+ BugBunny.configuration.on_return = lambda { |ri, _props, _body|
576
+ captured = { rk: ri.routing_key }
577
+ }
578
+
579
+ stub_return_during_wait(return_producer, return_info)
580
+
581
+ expect { return_producer.confirmed(build_mandatory_request) }
582
+ .to raise_error(BugBunny::PublishUnroutable)
583
+ expect(captured).to eq(rk: 'acct.unbound')
584
+ end
585
+
586
+ it 'levanta PublishUnroutable aunque el user_cb on_return explote' do
587
+ BugBunny.configuration.on_return = ->(_, _, _) { raise 'boom in user cb' }
588
+ stub_return_during_wait(return_producer, return_info)
589
+
590
+ expect { return_producer.confirmed(build_mandatory_request) }
591
+ .to raise_error(BugBunny::PublishUnroutable)
592
+ end
593
+ end
387
594
  end
388
595
  end
@@ -76,5 +76,21 @@ RSpec.describe BugBunny::Request do
76
76
  expect(req.mandatory).to be(true)
77
77
  expect(req.confirm_timeout).to eq(0.5)
78
78
  end
79
+
80
+ it 'tiene return_raise=nil por defecto (delega a config global)' do
81
+ req = described_class.new('foo')
82
+
83
+ expect(req.return_raise).to be_nil
84
+ end
85
+
86
+ it 'permite asignar return_raise' do
87
+ req = described_class.new('foo')
88
+
89
+ req.return_raise = false
90
+ expect(req.return_raise).to be(false)
91
+
92
+ req.return_raise = true
93
+ expect(req.return_raise).to be(true)
94
+ end
79
95
  end
80
96
  end
@@ -151,6 +151,78 @@ RSpec.describe BugBunny::Session do
151
151
  end
152
152
  end
153
153
 
154
+ describe '#register_return_listener / #unregister_return_listener' do
155
+ let(:properties_for) do
156
+ ->(cid) { double('properties', correlation_id: cid) }
157
+ end
158
+
159
+ let(:return_info) do
160
+ Struct.new(:reply_code, :reply_text, :exchange, :routing_key)
161
+ .new(312, 'NO_ROUTE', 'evt_x', 'rk')
162
+ end
163
+
164
+ before do
165
+ BugBunny.configuration.on_return = nil
166
+ end
167
+
168
+ after do
169
+ BugBunny.configuration.on_return = nil
170
+ end
171
+
172
+ it 'devuelve un Concurrent::Event y un slot que se setean al disparar el return' do
173
+ event, slot = session.register_return_listener('corr-1')
174
+
175
+ expect(event).to be_a(Concurrent::Event)
176
+ expect(slot[:info]).to be_nil
177
+
178
+ session.send(:handle_broker_return, return_info, properties_for.call('corr-1'), 'payload')
179
+
180
+ expect(event.set?).to be(true)
181
+ expect(slot[:info]).to eq(return_info)
182
+ end
183
+
184
+ it 'no toca otros listeners cuando llega un return de otro correlation_id' do
185
+ event_a, slot_a = session.register_return_listener('corr-A')
186
+ event_b, slot_b = session.register_return_listener('corr-B')
187
+
188
+ session.send(:handle_broker_return, return_info, properties_for.call('corr-A'), 'p')
189
+
190
+ expect(event_a.set?).to be(true)
191
+ expect(slot_a[:info]).to eq(return_info)
192
+ expect(event_b.set?).to be(false)
193
+ expect(slot_b[:info]).to be_nil
194
+ end
195
+
196
+ it 'ignora returns sin correlation_id en properties' do
197
+ event, slot = session.register_return_listener('corr-1')
198
+ props = double('properties', correlation_id: nil)
199
+
200
+ expect { session.send(:handle_broker_return, return_info, props, 'p') }.not_to raise_error
201
+ expect(event.set?).to be(false)
202
+ expect(slot[:info]).to be_nil
203
+ end
204
+
205
+ it '#unregister_return_listener limpia el slot del registry' do
206
+ session.register_return_listener('corr-1')
207
+ session.unregister_return_listener('corr-1')
208
+
209
+ registry = session.instance_variable_get(:@pending_returns)
210
+ expect(registry['corr-1']).to be_nil
211
+ end
212
+
213
+ it 'setea el event ANTES de invocar el callback global on_return (resiliencia a user_cb que explota)' do
214
+ BugBunny.configuration.on_return = ->(_, _, _) { raise 'boom in user callback' }
215
+
216
+ event, slot = session.register_return_listener('corr-1')
217
+
218
+ expect { session.send(:handle_broker_return, return_info, properties_for.call('corr-1'), 'p') }
219
+ .not_to raise_error
220
+
221
+ expect(event.set?).to be(true)
222
+ expect(slot[:info]).to eq(return_info)
223
+ end
224
+ end
225
+
154
226
  describe '#close' do
155
227
  it 'cierra el canal y lo nilifica' do
156
228
  session.channel
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.14.0
4
+ version: 4.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-12 00:00:00.000000000 Z
11
+ date: 2026-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -275,6 +275,7 @@ files:
275
275
  - spec/integration/controller_spec.rb
276
276
  - spec/integration/error_handling_spec.rb
277
277
  - spec/integration/infrastructure_spec.rb
278
+ - spec/integration/publisher_confirms_spec.rb
278
279
  - spec/integration/resource_spec.rb
279
280
  - spec/spec_helper.rb
280
281
  - spec/support/bunny_mocks.rb
@@ -303,7 +304,7 @@ metadata:
303
304
  homepage_uri: https://github.com/gedera/bug_bunny
304
305
  source_code_uri: https://github.com/gedera/bug_bunny
305
306
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
306
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.14.0/skill
307
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.16.0/skill
307
308
  post_install_message:
308
309
  rdoc_options: []
309
310
  require_paths: