bug_bunny 4.6.0 → 4.7.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/pr.md +42 -0
  3. data/.claude/commands/release.md +41 -0
  4. data/.claude/commands/rubocop.md +22 -0
  5. data/.claude/commands/test.md +28 -0
  6. data/.claude/commands/yard.md +46 -0
  7. data/CHANGELOG.md +42 -15
  8. data/CLAUDE.md +228 -0
  9. data/README.md +154 -221
  10. data/Rakefile +19 -3
  11. data/docs/concepts.md +140 -0
  12. data/docs/howto/controller.md +194 -0
  13. data/docs/howto/middleware_client.md +119 -0
  14. data/docs/howto/middleware_consumer.md +127 -0
  15. data/docs/howto/rails.md +214 -0
  16. data/docs/howto/resource.md +200 -0
  17. data/docs/howto/routing.md +133 -0
  18. data/docs/howto/testing.md +259 -0
  19. data/docs/howto/tracing.md +119 -0
  20. data/lib/bug_bunny/client.rb +41 -18
  21. data/lib/bug_bunny/configuration.rb +63 -0
  22. data/lib/bug_bunny/consumer.rb +51 -37
  23. data/lib/bug_bunny/consumer_middleware.rb +14 -5
  24. data/lib/bug_bunny/controller.rb +29 -4
  25. data/lib/bug_bunny/exception.rb +4 -0
  26. data/lib/bug_bunny/observability.rb +24 -3
  27. data/lib/bug_bunny/resource.rb +31 -21
  28. data/lib/bug_bunny/routing/route.rb +6 -1
  29. data/lib/bug_bunny/routing/route_set.rb +30 -3
  30. data/lib/bug_bunny/session.rb +18 -11
  31. data/lib/bug_bunny/version.rb +1 -1
  32. data/lib/bug_bunny.rb +1 -0
  33. data/mejoras.md +33 -0
  34. data/plan_test.txt +63 -0
  35. data/spec/integration/client_spec.rb +117 -0
  36. data/spec/integration/consumer_middleware_spec.rb +86 -0
  37. data/spec/integration/controller_spec.rb +140 -0
  38. data/spec/integration/error_handling_spec.rb +57 -0
  39. data/spec/integration/infrastructure_spec.rb +52 -0
  40. data/spec/integration/resource_spec.rb +113 -0
  41. data/spec/spec_helper.rb +70 -0
  42. data/spec/support/bunny_mocks.rb +18 -0
  43. data/spec/support/integration_helper.rb +87 -0
  44. data/spec/unit/client_session_pool_spec.rb +159 -0
  45. data/spec/unit/configuration_spec.rb +164 -0
  46. data/spec/unit/consumer_middleware_spec.rb +129 -0
  47. data/spec/unit/consumer_spec.rb +90 -0
  48. data/spec/unit/controller_after_action_spec.rb +155 -0
  49. data/spec/unit/observability_spec.rb +167 -0
  50. data/spec/unit/resource_attributes_spec.rb +69 -0
  51. data/spec/unit/session_spec.rb +98 -0
  52. metadata +36 -3
  53. data/sig/bug_bunny.rbs +0 -4
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/integration_helper'
5
+
6
+ # Controladores de prueba aislados en namespace propio
7
+ module InfraSpec
8
+ class PingController < BugBunny::Controller
9
+ def index
10
+ render status: 200, json: { message: 'pong', namespace: 'InfraSpec' }
11
+ end
12
+
13
+ def show
14
+ render status: 200, json: { id: params[:id], message: 'pong', namespace: 'InfraSpec' }
15
+ end
16
+ end
17
+ end
18
+
19
+ class InfraSpecResource < BugBunny::Resource
20
+ self.resource_name = 'ping'
21
+ self.exchange = 'infra_spec_exchange'
22
+ self.exchange_type = 'topic'
23
+ end
24
+
25
+ RSpec.describe 'Infrastructure', :integration do
26
+ let(:queue) { unique('infra_q') }
27
+ let(:exchange) { 'infra_spec_exchange' }
28
+
29
+ before do
30
+ BugBunny.configure { |c| c.controller_namespace = 'InfraSpec' }
31
+ end
32
+
33
+ after do
34
+ BugBunny.configure { |c| c.controller_namespace = 'BugBunny::Controllers' }
35
+ end
36
+
37
+ it 'levanta el worker y cede el control al bloque' do
38
+ with_running_worker(queue: queue, exchange: exchange) do
39
+ expect(true).to be(true)
40
+ end
41
+ end
42
+
43
+ it 'resuelve el namespace del controlador dinámicamente' do
44
+ with_running_worker(queue: queue, exchange: exchange) do
45
+ resource = InfraSpecResource.find('42')
46
+
47
+ expect(resource.id).to eq('42')
48
+ expect(resource.namespace).to eq('InfraSpec')
49
+ expect(resource.message).to eq('pong')
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/integration_helper'
5
+
6
+ module ResourceSpec
7
+ class NodeController < BugBunny::Controller
8
+ def index
9
+ nodes = params[:q] ? [{ id: '1', status: params.dig(:q, :status) }] : [{ id: '1' }, { id: '2' }]
10
+ render status: 200, json: nodes
11
+ end
12
+
13
+ def show
14
+ render status: 200, json: { id: params[:id], name: 'node-01' }
15
+ end
16
+
17
+ def create
18
+ render status: 201, json: { id: '99', name: params.dig(:node, :name) }
19
+ end
20
+
21
+ def update
22
+ render status: 200, json: { id: params[:id], updated: true, name: params.dig(:node, 'name') }
23
+ end
24
+
25
+ def destroy
26
+ render status: 200, json: { id: params[:id], deleted: true }
27
+ end
28
+ end
29
+ end
30
+
31
+ class SpecNode < BugBunny::Resource
32
+ self.resource_name = 'node'
33
+ self.param_key = 'node'
34
+ self.exchange = 'resource_spec_exchange'
35
+ self.exchange_type = 'topic'
36
+ end
37
+
38
+ RSpec.describe BugBunny::Resource, :integration do
39
+ let(:queue) { unique('resource_q') }
40
+ let(:exchange) { 'resource_spec_exchange' }
41
+
42
+ before { BugBunny.configure { |c| c.controller_namespace = 'ResourceSpec' } }
43
+ after { BugBunny.configure { |c| c.controller_namespace = 'BugBunny::Controllers' } }
44
+
45
+ describe '.find' do
46
+ it 'retorna el recurso por id' do
47
+ with_running_worker(queue: queue, exchange: exchange) do
48
+ node = SpecNode.find('1')
49
+
50
+ expect(node.id).to eq('1')
51
+ expect(node.name).to eq('node-01')
52
+ end
53
+ end
54
+ end
55
+
56
+ describe '.where' do
57
+ it 'retorna todos los recursos sin filtros' do
58
+ with_running_worker(queue: queue, exchange: exchange) do
59
+ nodes = SpecNode.where
60
+
61
+ expect(nodes).to be_an(Array)
62
+ expect(nodes.length).to eq(2)
63
+ end
64
+ end
65
+
66
+ it 'pasa los filtros como query params al controlador' do
67
+ with_running_worker(queue: queue, exchange: exchange) do
68
+ nodes = SpecNode.where(q: { status: 'active' })
69
+
70
+ expect(nodes).to be_an(Array)
71
+ expect(nodes.first.status).to eq('active')
72
+ end
73
+ end
74
+ end
75
+
76
+ describe '.create' do
77
+ it 'crea el recurso y retorna el objeto creado' do
78
+ with_running_worker(queue: queue, exchange: exchange) do
79
+ node = SpecNode.create(name: 'node-nuevo')
80
+
81
+ expect(node.id).to eq('99')
82
+ expect(node.name).to eq('node-nuevo')
83
+ end
84
+ end
85
+ end
86
+
87
+ describe '#update' do
88
+ it 'actualiza el recurso por id' do
89
+ with_running_worker(queue: queue, exchange: exchange) do
90
+ node = SpecNode.new(id: '1')
91
+ node.persisted = true
92
+ result = node.update(name: 'node-actualizado')
93
+
94
+ expect(result).to be(true)
95
+ expect(node.id).to eq('1')
96
+ expect(node.updated).to be(true)
97
+ end
98
+ end
99
+ end
100
+
101
+ describe '#destroy' do
102
+ it 'elimina el recurso por id' do
103
+ with_running_worker(queue: queue, exchange: exchange) do
104
+ node = SpecNode.new(id: '1')
105
+ node.persisted = true
106
+ result = node.destroy
107
+
108
+ expect(result).to be(true)
109
+ expect(node.persisted?).to be(false)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'bug_bunny'
5
+ require 'connection_pool'
6
+ require 'socket'
7
+
8
+ # Carga variables de entorno desde .env si existe
9
+ env_file = File.join(__dir__, '..', '.env')
10
+ if File.exist?(env_file)
11
+ File.readlines(env_file).each do |line|
12
+ line = line.strip
13
+ next if line.empty? || line.start_with?('#')
14
+
15
+ key, value = line.split('=', 2)
16
+ ENV[key.strip] = value.strip.delete("'\"") if key && value
17
+ end
18
+ end
19
+
20
+ BugBunny.configure do |config|
21
+ config.host = ENV.fetch('RABBITMQ_HOST', 'localhost')
22
+ config.username = ENV.fetch('RABBITMQ_USER', 'guest')
23
+ config.password = ENV.fetch('RABBITMQ_PASS', 'guest')
24
+ config.vhost = '/'
25
+ config.logger = Logger.new($stdout).tap { |l| l.level = Logger::WARN }
26
+ config.exchange_options = { durable: false, auto_delete: true }
27
+ config.queue_options = { exclusive: false, durable: false, auto_delete: true }
28
+ end
29
+
30
+ TEST_POOL ||= ConnectionPool.new(size: 5, timeout: 5) { BugBunny.create_connection }
31
+ BugBunny::Resource.connection_pool = TEST_POOL
32
+
33
+ # Routes globales para todos los specs
34
+ BugBunny.routes.draw do
35
+ resources :ping
36
+ resources :node
37
+ resources :user
38
+ get 'around', to: 'around#index'
39
+ get 'rescue', to: 'rescue#index'
40
+ get 'boom', to: 'boom#index'
41
+ get 'echo', to: 'echo#index'
42
+ post 'events', to: 'event#create'
43
+ end
44
+
45
+ require 'support/integration_helper'
46
+ require 'support/bunny_mocks'
47
+
48
+ RSpec.configure do |config|
49
+ config.expect_with :rspec do |expectations|
50
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
51
+ end
52
+
53
+ config.mock_with :rspec do |mocks|
54
+ mocks.verify_partial_doubles = true
55
+ end
56
+
57
+ config.shared_context_metadata_behavior = :apply_to_host_groups
58
+ config.filter_run_when_matching :focus
59
+ config.disable_monkey_patching!
60
+ config.warnings = false
61
+ config.order = :random
62
+ Kernel.srand config.seed
63
+
64
+ # Skippea tests de integración si RabbitMQ no está disponible
65
+ config.before(:each, :integration) do
66
+ skip 'RabbitMQ no disponible' unless rabbitmq_available?
67
+ end
68
+
69
+ config.include_context 'integration helpers', :integration
70
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stubs livianos de Bunny para specs unitarios que no necesitan RabbitMQ real.
4
+
5
+ module BunnyMocks
6
+ FakeChannel = Struct.new(:open) do
7
+ def open? = open
8
+ def close = (self.open = false)
9
+ def confirm_select; end
10
+ def prefetch(_n); end
11
+ end
12
+
13
+ FakeConnection = Struct.new(:open, :channel_to_return) do
14
+ def open? = open
15
+ def start = (self.open = true)
16
+ def create_channel = channel_to_return
17
+ end
18
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ # Helpers compartidos para specs de integración con RabbitMQ real.
6
+ # Incluido automáticamente en todos los specs marcados con :integration.
7
+ RSpec.shared_context 'integration helpers' do
8
+ def rabbitmq_available?
9
+ conn = BugBunny.create_connection
10
+ conn.start
11
+ conn.close
12
+ true
13
+ rescue StandardError
14
+ false
15
+ end
16
+
17
+ # Levanta un Consumer real en un thread separado y cede el control al bloque.
18
+ # El consumer se detiene al salir del bloque.
19
+ #
20
+ # @param queue [String] nombre de la cola
21
+ # @param exchange [String] nombre del exchange
22
+ # @param exchange_type [String] tipo de exchange
23
+ # @param routing_key [String] routing key de binding
24
+ def with_running_worker(queue:, exchange:, exchange_type: 'topic', routing_key: '#')
25
+ conn = BugBunny.create_connection
26
+ consumer = BugBunny::Consumer.new(conn)
27
+
28
+ worker_thread = Thread.new do
29
+ consumer.subscribe(
30
+ queue_name: queue,
31
+ exchange_name: exchange,
32
+ exchange_type: exchange_type,
33
+ routing_key: routing_key,
34
+ block: true
35
+ )
36
+ rescue StandardError => e
37
+ warn "WORKER ERROR: #{e.message}"
38
+ end
39
+
40
+ sleep 0.5
41
+ yield
42
+ ensure
43
+ consumer.shutdown rescue nil
44
+ conn&.close rescue nil
45
+ worker_thread&.kill
46
+ sleep 0.1
47
+ end
48
+
49
+ # Levanta un worker espía que captura mensajes raw sin procesarlos.
50
+ # Útil para verificar que el mensaje llegó con el routing key y headers correctos.
51
+ #
52
+ # @yieldparam messages [Thread::Queue] cola thread-safe donde llegan los mensajes
53
+ def with_spy_worker(queue:, exchange:, exchange_type: 'topic', routing_key: '#')
54
+ messages = Thread::Queue.new
55
+ conn = BugBunny.create_connection
56
+
57
+ worker_thread = Thread.new do
58
+ ch = conn.create_channel
59
+ x = ch.public_send(exchange_type, exchange, BugBunny.configuration.exchange_options)
60
+ q = ch.queue(queue, BugBunny.configuration.queue_options)
61
+ q.bind(x, routing_key: routing_key)
62
+ q.subscribe(block: true) do |delivery, props, body|
63
+ messages << { body: body, routing_key: delivery.routing_key, headers: props.headers }
64
+ end
65
+ rescue StandardError => e
66
+ warn "SPY ERROR: #{e.message}"
67
+ end
68
+
69
+ sleep 0.5
70
+ yield(messages)
71
+ ensure
72
+ conn&.close rescue nil
73
+ worker_thread&.kill
74
+ end
75
+
76
+ # Espera un mensaje de la Queue con timeout.
77
+ def wait_for_message(queue, timeout_sec = 3)
78
+ Timeout.timeout(timeout_sec) { queue.pop }
79
+ rescue Timeout::Error
80
+ raise "Timeout: no llegó ningún mensaje en #{timeout_sec}s"
81
+ end
82
+
83
+ # Genera nombres únicos para evitar colisiones entre tests.
84
+ def unique(name)
85
+ "#{name}_#{SecureRandom.hex(4)}"
86
+ end
87
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/bunny_mocks'
5
+
6
+ RSpec.describe BugBunny::Client, 'session pooling' do
7
+ include BunnyMocks
8
+
9
+ # Pool falso que siempre entrega la misma conexión.
10
+ def fake_pool(*conns)
11
+ index = 0
12
+ pool = Object.new
13
+ pool.define_singleton_method(:with) do |&block|
14
+ block.call(conns[index % conns.size])
15
+ ensure
16
+ index += 1
17
+ end
18
+ pool
19
+ end
20
+
21
+ def fake_conn
22
+ channel = BunnyMocks::FakeChannel.new(true)
23
+ BunnyMocks::FakeConnection.new(true, channel)
24
+ end
25
+
26
+ # Crea un cliente con un Producer stub que responde inmediatamente.
27
+ def client_with_pool(pool)
28
+ client = described_class.new(pool: pool)
29
+ # Stub Producer#rpc para que no toque RabbitMQ real
30
+ allow_any_instance_of(BugBunny::Producer).to receive(:rpc) do |_prod, req|
31
+ { 'status' => 200, 'body' => '{"ok":true}' }
32
+ end
33
+ allow_any_instance_of(BugBunny::Producer).to receive(:fire) do |_prod, _req|
34
+ { 'status' => 202, 'body' => nil }
35
+ end
36
+ client
37
+ end
38
+
39
+ describe 'Session reuse' do
40
+ it 'crea una sola Session aunque se hagan múltiples requests a la misma conexión' do
41
+ conn = fake_conn
42
+ client = client_with_pool(fake_pool(conn))
43
+
44
+ session_new_count = 0
45
+ allow(BugBunny::Session).to receive(:new).and_wrap_original do |orig, *args, **kwargs|
46
+ session_new_count += 1
47
+ orig.call(*args, **kwargs)
48
+ end
49
+
50
+ 3.times { client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct') }
51
+
52
+ expect(session_new_count).to eq(1)
53
+ end
54
+
55
+ it 'retorna la misma instancia de Session en cada request' do
56
+ conn = fake_conn
57
+ client = client_with_pool(fake_pool(conn))
58
+
59
+ client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct')
60
+ session_after_first = conn.instance_variable_get(:@_bug_bunny_session)
61
+
62
+ client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct')
63
+ session_after_second = conn.instance_variable_get(:@_bug_bunny_session)
64
+
65
+ expect(session_after_first).to be(session_after_second)
66
+ end
67
+
68
+ it 'crea Sessions distintas para conexiones distintas' do
69
+ conn_a = fake_conn
70
+ conn_b = fake_conn
71
+ pool = fake_pool(conn_a, conn_b)
72
+ client = client_with_pool(pool)
73
+
74
+ client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct')
75
+ client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct')
76
+
77
+ session_a = conn_a.instance_variable_get(:@_bug_bunny_session)
78
+ session_b = conn_b.instance_variable_get(:@_bug_bunny_session)
79
+
80
+ expect(session_a).not_to be_nil
81
+ expect(session_b).not_to be_nil
82
+ expect(session_a).not_to be(session_b)
83
+ end
84
+ end
85
+
86
+ describe 'Producer reuse' do
87
+ it 'crea un solo Producer aunque se hagan múltiples requests a la misma conexión' do
88
+ conn = fake_conn
89
+ client = client_with_pool(fake_pool(conn))
90
+
91
+ producer_new_count = 0
92
+ allow(BugBunny::Producer).to receive(:new).and_wrap_original do |orig, *args|
93
+ producer_new_count += 1
94
+ orig.call(*args)
95
+ end
96
+
97
+ 3.times { client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct') }
98
+
99
+ expect(producer_new_count).to eq(1)
100
+ end
101
+
102
+ it 'retorna la misma instancia de Producer en cada request' do
103
+ conn = fake_conn
104
+ client = client_with_pool(fake_pool(conn))
105
+
106
+ client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct')
107
+ producer_after_first = conn.instance_variable_get(:@_bug_bunny_producer)
108
+
109
+ client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct')
110
+ producer_after_second = conn.instance_variable_get(:@_bug_bunny_producer)
111
+
112
+ expect(producer_after_first).to be(producer_after_second)
113
+ end
114
+ end
115
+
116
+ describe 'thread-safety' do
117
+ it 'múltiples threads con la misma conexión no generan Sessions duplicadas' do
118
+ conn = fake_conn
119
+ # Pool siempre devuelve la misma conexión — simula concurrencia en el mismo slot
120
+ pool = Object.new
121
+ mutex = Mutex.new
122
+ pool.define_singleton_method(:with) { |&blk| mutex.synchronize { blk.call(conn) } }
123
+
124
+ client = client_with_pool(pool)
125
+
126
+ session_new_count = Concurrent::AtomicFixnum.new(0)
127
+ allow(BugBunny::Session).to receive(:new).and_wrap_original do |orig, *args, **kwargs|
128
+ session_new_count.increment
129
+ orig.call(*args, **kwargs)
130
+ end
131
+
132
+ threads = 10.times.map do
133
+ Thread.new { client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct') }
134
+ end
135
+ threads.each(&:join)
136
+
137
+ expect(session_new_count.value).to eq(1)
138
+ end
139
+ end
140
+
141
+ describe 'Session no se cierra entre requests' do
142
+ it 'no invoca close en la Session al terminar el request' do
143
+ conn = fake_conn
144
+ client = client_with_pool(fake_pool(conn))
145
+
146
+ # Primera request para crear y cachear la session
147
+ client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct')
148
+
149
+ session = conn.instance_variable_get(:@_bug_bunny_session)
150
+ expect(session).not_to be_nil
151
+
152
+ # Espiamos la session cacheada y ejecutamos una segunda request
153
+ allow(session).to receive(:close).and_call_original
154
+ client.request('ping', method: :get, exchange: 'x', exchange_type: 'direct')
155
+
156
+ expect(session).not_to have_received(:close)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe BugBunny::Configuration do
6
+ # Construye una configuración con los defaults + overrides dados, llama validate!.
7
+ def configure_with(**overrides)
8
+ BugBunny.configuration = BugBunny::Configuration.new
9
+ BugBunny.configure do |c|
10
+ overrides.each { |attr, val| c.send(:"#{attr}=", val) }
11
+ end
12
+ end
13
+
14
+ after { BugBunny.configuration = BugBunny::Configuration.new }
15
+
16
+ describe 'defaults' do
17
+ it 'pasan validate! sin ninguna configuración adicional' do
18
+ expect { configure_with }.not_to raise_error
19
+ end
20
+ end
21
+
22
+ describe 'host' do
23
+ it 'levanta ConfigurationError si es nil' do
24
+ expect { configure_with(host: nil) }
25
+ .to raise_error(BugBunny::ConfigurationError, /host is required/)
26
+ end
27
+
28
+ it 'levanta ConfigurationError si es string vacío' do
29
+ expect { configure_with(host: '') }
30
+ .to raise_error(BugBunny::ConfigurationError, /host is required/)
31
+ end
32
+
33
+ it 'levanta ConfigurationError si no es String' do
34
+ expect { configure_with(host: 12_345) }
35
+ .to raise_error(BugBunny::ConfigurationError, /host must be a String/)
36
+ end
37
+ end
38
+
39
+ describe 'port' do
40
+ it 'levanta ConfigurationError si es un String' do
41
+ expect { configure_with(port: 'invalid') }
42
+ .to raise_error(BugBunny::ConfigurationError, /port must be a Integer/)
43
+ end
44
+
45
+ it 'levanta ConfigurationError si es 0 (fuera de rango)' do
46
+ expect { configure_with(port: 0) }
47
+ .to raise_error(BugBunny::ConfigurationError, /port must be in/)
48
+ end
49
+
50
+ it 'levanta ConfigurationError si es 99999 (fuera de rango)' do
51
+ expect { configure_with(port: 99_999) }
52
+ .to raise_error(BugBunny::ConfigurationError, /port must be in/)
53
+ end
54
+
55
+ it 'acepta valores en los límites del rango (1 y 65535)' do
56
+ expect { configure_with(port: 1) }.not_to raise_error
57
+ expect { configure_with(port: 65_535) }.not_to raise_error
58
+ end
59
+
60
+ it 'acepta 5672 (default de RabbitMQ)' do
61
+ expect { configure_with(port: 5672) }.not_to raise_error
62
+ end
63
+ end
64
+
65
+ describe 'username / password' do
66
+ it 'levanta ConfigurationError si username es nil' do
67
+ expect { configure_with(username: nil) }
68
+ .to raise_error(BugBunny::ConfigurationError, /username is required/)
69
+ end
70
+
71
+ it 'levanta ConfigurationError si password es nil' do
72
+ expect { configure_with(password: nil) }
73
+ .to raise_error(BugBunny::ConfigurationError, /password is required/)
74
+ end
75
+
76
+ it 'levanta ConfigurationError si username no es String' do
77
+ expect { configure_with(username: 123) }
78
+ .to raise_error(BugBunny::ConfigurationError, /username must be a String/)
79
+ end
80
+ end
81
+
82
+ describe 'heartbeat' do
83
+ it 'acepta 0 (heartbeat deshabilitado)' do
84
+ expect { configure_with(heartbeat: 0) }.not_to raise_error
85
+ end
86
+
87
+ it 'levanta ConfigurationError si es negativo' do
88
+ expect { configure_with(heartbeat: -1) }
89
+ .to raise_error(BugBunny::ConfigurationError, /heartbeat must be in/)
90
+ end
91
+
92
+ it 'levanta ConfigurationError si supera 3600' do
93
+ expect { configure_with(heartbeat: 3_601) }
94
+ .to raise_error(BugBunny::ConfigurationError, /heartbeat must be in/)
95
+ end
96
+
97
+ it 'levanta ConfigurationError si no es Integer' do
98
+ expect { configure_with(heartbeat: '30') }
99
+ .to raise_error(BugBunny::ConfigurationError, /heartbeat must be a Integer/)
100
+ end
101
+ end
102
+
103
+ describe 'rpc_timeout' do
104
+ it 'levanta ConfigurationError si es negativo' do
105
+ expect { configure_with(rpc_timeout: -1) }
106
+ .to raise_error(BugBunny::ConfigurationError, /rpc_timeout must be in/)
107
+ end
108
+
109
+ it 'levanta ConfigurationError si es 0' do
110
+ expect { configure_with(rpc_timeout: 0) }
111
+ .to raise_error(BugBunny::ConfigurationError, /rpc_timeout must be in/)
112
+ end
113
+ end
114
+
115
+ describe 'channel_prefetch' do
116
+ it 'levanta ConfigurationError si es 0' do
117
+ expect { configure_with(channel_prefetch: 0) }
118
+ .to raise_error(BugBunny::ConfigurationError, /channel_prefetch must be in/)
119
+ end
120
+
121
+ it 'levanta ConfigurationError si supera 10000' do
122
+ expect { configure_with(channel_prefetch: 10_001) }
123
+ .to raise_error(BugBunny::ConfigurationError, /channel_prefetch must be in/)
124
+ end
125
+ end
126
+
127
+ describe 'configuración válida completa' do
128
+ it 'acepta todos los atributos con valores correctos' do
129
+ expect do
130
+ configure_with(
131
+ host: '10.0.0.1',
132
+ port: 5673,
133
+ username: 'myuser',
134
+ password: 'mypass',
135
+ vhost: '/production',
136
+ heartbeat: 30,
137
+ rpc_timeout: 5,
138
+ channel_prefetch: 10
139
+ )
140
+ end.not_to raise_error
141
+ end
142
+ end
143
+
144
+ describe 'atributos opcionales nil' do
145
+ it 'acepta nil en atributos no requeridos (max_reconnect_attempts, health_check_file)' do
146
+ expect do
147
+ configure_with(max_reconnect_attempts: nil, health_check_file: nil)
148
+ end.not_to raise_error
149
+ end
150
+ end
151
+
152
+ describe '.validate! directamente' do
153
+ it 'es invocable directamente sobre la instancia' do
154
+ config = BugBunny::Configuration.new
155
+ expect { config.validate! }.not_to raise_error
156
+ end
157
+
158
+ it 'levanta ConfigurationError si el estado es inválido' do
159
+ config = BugBunny::Configuration.new
160
+ config.port = 'bad'
161
+ expect { config.validate! }.to raise_error(BugBunny::ConfigurationError)
162
+ end
163
+ end
164
+ end