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.
- checksums.yaml +4 -4
- data/.claude/commands/pr.md +42 -0
- data/.claude/commands/release.md +41 -0
- data/.claude/commands/rubocop.md +22 -0
- data/.claude/commands/test.md +28 -0
- data/.claude/commands/yard.md +46 -0
- data/CHANGELOG.md +42 -15
- data/CLAUDE.md +228 -0
- data/README.md +154 -221
- data/Rakefile +19 -3
- data/docs/concepts.md +140 -0
- data/docs/howto/controller.md +194 -0
- data/docs/howto/middleware_client.md +119 -0
- data/docs/howto/middleware_consumer.md +127 -0
- data/docs/howto/rails.md +214 -0
- data/docs/howto/resource.md +200 -0
- data/docs/howto/routing.md +133 -0
- data/docs/howto/testing.md +259 -0
- data/docs/howto/tracing.md +119 -0
- data/lib/bug_bunny/client.rb +41 -18
- data/lib/bug_bunny/configuration.rb +63 -0
- data/lib/bug_bunny/consumer.rb +51 -37
- data/lib/bug_bunny/consumer_middleware.rb +14 -5
- data/lib/bug_bunny/controller.rb +29 -4
- data/lib/bug_bunny/exception.rb +4 -0
- data/lib/bug_bunny/observability.rb +24 -3
- data/lib/bug_bunny/resource.rb +31 -21
- data/lib/bug_bunny/routing/route.rb +6 -1
- data/lib/bug_bunny/routing/route_set.rb +30 -3
- data/lib/bug_bunny/session.rb +18 -11
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +1 -0
- data/mejoras.md +33 -0
- data/plan_test.txt +63 -0
- data/spec/integration/client_spec.rb +117 -0
- data/spec/integration/consumer_middleware_spec.rb +86 -0
- data/spec/integration/controller_spec.rb +140 -0
- data/spec/integration/error_handling_spec.rb +57 -0
- data/spec/integration/infrastructure_spec.rb +52 -0
- data/spec/integration/resource_spec.rb +113 -0
- data/spec/spec_helper.rb +70 -0
- data/spec/support/bunny_mocks.rb +18 -0
- data/spec/support/integration_helper.rb +87 -0
- data/spec/unit/client_session_pool_spec.rb +159 -0
- data/spec/unit/configuration_spec.rb +164 -0
- data/spec/unit/consumer_middleware_spec.rb +129 -0
- data/spec/unit/consumer_spec.rb +90 -0
- data/spec/unit/controller_after_action_spec.rb +155 -0
- data/spec/unit/observability_spec.rb +167 -0
- data/spec/unit/resource_attributes_spec.rb +69 -0
- data/spec/unit/session_spec.rb +98 -0
- metadata +36 -3
- 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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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
|