bug_bunny 3.0.6 → 3.1.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.
@@ -1,71 +1,44 @@
1
- # lib/bug_bunny/resource.rb
1
+ # frozen_string_literal: true
2
+
2
3
  require 'active_model'
3
4
  require 'active_support/core_ext/string/inflections'
4
5
  require 'uri'
6
+ require 'set' # Necesario para el tracking manual
5
7
  require 'rack/utils'
6
8
 
7
9
  module BugBunny
8
10
  # Clase base para modelos remotos que implementan **Active Record over AMQP (RESTful)**.
9
11
  #
10
- # Esta clase transforma operaciones CRUD estándar en peticiones RPC utilizando
11
- # verbos HTTP semánticos (GET, POST, PUT, DELETE) transportados sobre headers AMQP.
12
- #
13
- # También gestiona la serialización automática de parámetros ("wrapping") para
14
- # compatibilidad con Strong Parameters de Rails.
12
+ # Soporta un esquema híbrido de datos:
13
+ # 1. **Atributos Tipados:** Definidos con `attribute :name, :type`.
14
+ # 2. **Atributos Dinámicos:** Asignados al vuelo sin definición previa.
15
15
  #
16
- # @example Definición de un recurso
17
- # class User < BugBunny::Resource
18
- # self.exchange = 'app.topic'
19
- # self.resource_name = 'users'
20
- # # Opcional: Personalizar la clave raíz del JSON
21
- # self.param_key = 'user_data'
22
- # end
16
+ # Implementa un sistema de "Dirty Tracking" híbrido para detectar cambios
17
+ # tanto en atributos tipados (via ActiveModel) como dinámicos (via Set manual).
23
18
  #
24
- # @example Uso con contexto temporal
25
- # # La instancia 'user' recordará que debe usar la routing_key 'urgent'
26
- # user = User.with(routing_key: 'urgent').new(name: 'Gaby')
27
- # user.save # Enviará a la cola 'urgent' aunque estemos fuera del bloque .with
19
+ # @author Gabriel
20
+ # @since 3.0.6
28
21
  class Resource
29
22
  include ActiveModel::API
23
+ include ActiveModel::Attributes
30
24
  include ActiveModel::Dirty
31
25
  include ActiveModel::Validations
32
26
  extend ActiveModel::Callbacks
33
27
 
34
28
  define_model_callbacks :save, :create, :update, :destroy
35
29
 
36
- # @return [HashWithIndifferentAccess] Contenedor de los atributos remotos (JSON crudo).
37
30
  attr_reader :remote_attributes
38
-
39
- # @return [Boolean] Indica si el objeto ha sido guardado en el servicio remoto.
40
31
  attr_accessor :persisted
41
-
42
- # @return [String, nil] Routing Key capturada en el momento de la instanciación.
43
- attr_accessor :routing_key
44
-
45
- # @return [String, nil] Exchange capturado en el momento de la instanciación.
46
- attr_accessor :exchange
47
-
48
- # @return [String, nil] Tipo de Exchange capturado en el momento de la instanciación.
49
- attr_accessor :exchange_type
32
+ attr_accessor :routing_key, :exchange, :exchange_type
50
33
 
51
34
  class << self
52
- # Configuración heredable
53
35
  attr_writer :connection_pool, :exchange, :exchange_type, :resource_name, :routing_key, :param_key
54
36
 
55
- # Lee la configuración del Thread actual (usado por el scope .with).
56
- # @api private
57
- def thread_config(key)
58
- Thread.current["bb_#{object_id}_#{key}"]
59
- end
37
+ def thread_config(key); Thread.current["bb_#{object_id}_#{key}"]; end
60
38
 
61
- # Resuelve la configuración buscando en: 1. Thread (Scope), 2. Clase, 3. Herencia.
62
- # @api private
63
39
  def resolve_config(key, instance_var)
64
- # 1. Prioridad: Contexto de hilo (.with)
65
40
  val = thread_config(key)
66
41
  return val if val
67
-
68
- # 2. Prioridad: Jerarquía de clases
69
42
  target = self
70
43
  while target <= BugBunny::Resource
71
44
  value = target.instance_variable_get(instance_var)
@@ -75,38 +48,23 @@ module BugBunny
75
48
  nil
76
49
  end
77
50
 
78
- # @return [ConnectionPool] El pool de conexiones asignado.
79
51
  def connection_pool; resolve_config(:pool, :@connection_pool); end
80
-
81
- # @return [String] El exchange configurado.
82
- # @raise [ArgumentError] Si no se ha definido un exchange.
83
52
  def current_exchange; resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined"); end
84
-
85
- # @return [String] El tipo de exchange (default: direct).
86
53
  def current_exchange_type; resolve_config(:exchange_type, :@exchange_type) || 'direct'; end
87
54
 
88
- # @return [String] El nombre del recurso (ej: 'users'). Se infiere del nombre de la clase si no existe.
89
55
  def resource_name
90
56
  resolve_config(:resource_name, :@resource_name) || name.demodulize.underscore.pluralize
91
57
  end
92
58
 
93
- # Define la clave raíz para envolver el payload JSON (Wrapping).
94
- #
95
- # Por defecto utiliza `model_name.element`, lo que elimina los namespaces.
96
- # Ej: `Manager::Service` -> `'service'`.
97
- #
98
- # @return [String] La clave paramétrica.
99
59
  def param_key
100
60
  resolve_config(:param_key, :@param_key) || model_name.element
101
61
  end
102
62
 
103
- # Define un middleware para el cliente HTTP/AMQP de este recurso.
104
63
  def client_middleware(&block)
105
64
  @client_middleware_stack ||= []
106
65
  @client_middleware_stack << block
107
66
  end
108
67
 
109
- # @api private
110
68
  def resolve_middleware_stack
111
69
  stack = []
112
70
  target = self
@@ -118,28 +76,19 @@ module BugBunny
118
76
  stack
119
77
  end
120
78
 
121
- # Instancia un cliente configurado con el pool y middlewares del recurso.
122
- # @return [BugBunny::Client]
123
79
  def bug_bunny_client
124
80
  pool = connection_pool
125
81
  raise BugBunny::Error, "Connection pool missing for #{name}" unless pool
126
-
127
82
  BugBunny::Client.new(pool: pool) do |conn|
128
83
  resolve_middleware_stack.each { |block| block.call(conn) }
129
84
  end
130
85
  end
131
86
 
132
- # Ejecuta un bloque (o retorna un Proxy) con una configuración temporal.
133
- # Útil para cambiar de exchange o routing_key para una operación específica.
134
- #
135
- # @example
136
- # User.with(routing_key: 'urgent').create(params)
137
87
  def with(exchange: nil, routing_key: nil, exchange_type: nil, pool: nil)
138
88
  keys = { exchange: "bb_#{object_id}_exchange", exchange_type: "bb_#{object_id}_exchange_type", pool: "bb_#{object_id}_pool", routing_key: "bb_#{object_id}_routing_key" }
139
89
  old_values = {}
140
90
  keys.each { |k, v| old_values[k] = Thread.current[v] }
141
91
 
142
- # Seteamos valores temporales
143
92
  Thread.current[keys[:exchange]] = exchange if exchange
144
93
  Thread.current[keys[:exchange_type]] = exchange_type if exchange_type
145
94
  Thread.current[keys[:pool]] = pool if pool
@@ -152,40 +101,25 @@ module BugBunny
152
101
  end
153
102
  end
154
103
 
155
- # Proxy para permitir encadenamiento: User.with(...).find(1)
156
104
  class ScopeProxy < BasicObject
157
105
  def initialize(target, keys, old_values); @target = target; @keys = keys; @old_values = old_values; end
158
106
  def method_missing(method, *args, &block); @target.public_send(method, *args, &block); ensure; @keys.each { |k, v| ::Thread.current[v] = @old_values[k] }; end
159
107
  end
160
108
 
161
- # Calcula la Routing Key.
162
- # @return [String]
163
109
  def calculate_routing_key(id = nil)
164
- # 1. Contexto .with
165
110
  manual_rk = thread_config(:routing_key)
166
111
  return manual_rk if manual_rk
167
-
168
- # 2. Configuración estática
169
112
  static_rk = resolve_config(:routing_key, :@routing_key)
170
113
  return static_rk if static_rk.present?
171
-
172
- # 3. Default: Resource name
173
114
  resource_name
174
115
  end
175
116
 
176
- # @!group Acciones CRUD RESTful (Clase)
177
-
178
- # Busca recursos que coincidan con los filtros.
179
- # Envía: GET resource?query
117
+ # @!group Acciones CRUD RESTful
180
118
  def where(filters = {})
181
119
  rk = calculate_routing_key
182
120
  path = resource_name
183
-
184
- # Usamos Rack para serializar anidamiento (q[service]=val)
185
121
  path += "?#{Rack::Utils.build_nested_query(filters)}" if filters.present?
186
-
187
122
  response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
188
-
189
123
  return [] unless response['body'].is_a?(Array)
190
124
  response['body'].map do |attrs|
191
125
  inst = new(attrs)
@@ -197,26 +131,18 @@ module BugBunny
197
131
 
198
132
  def all; where({}); end
199
133
 
200
- # Busca un recurso por ID.
201
- # Envía: GET resource/id
202
134
  def find(id)
203
135
  rk = calculate_routing_key(id)
204
136
  path = "#{resource_name}/#{id}"
205
-
206
137
  response = bug_bunny_client.request(path, method: :get, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
207
-
208
138
  return nil if response.nil? || response['status'] == 404
209
-
210
- attributes = response['body']
211
- return nil unless attributes.is_a?(Hash)
212
-
213
- instance = new(attributes)
139
+ return nil unless response['body'].is_a?(Hash)
140
+ instance = new(response['body'])
214
141
  instance.persisted = true
215
142
  instance.send(:clear_changes_information)
216
143
  instance
217
144
  end
218
145
 
219
- # Crea un nuevo recurso.
220
146
  def create(payload)
221
147
  instance = new(payload)
222
148
  instance.save
@@ -226,45 +152,32 @@ module BugBunny
226
152
 
227
153
  # @!group Instancia
228
154
 
229
- # Inicializa una nueva instancia del recurso.
230
- #
231
- # **IMPORTANTE:** Captura la configuración del contexto actual (`.with`)
232
- # y la guarda en la instancia. Esto permite que objetos creados dentro de un bloque `with`
233
- # mantengan esa configuración (routing_key, exchange) durante todo su ciclo de vida,
234
- # incluso si `save` se llama fuera del bloque.
235
- #
236
- # @param attributes [Hash] Atributos iniciales.
237
155
  def initialize(attributes = {})
238
156
  @remote_attributes = {}.with_indifferent_access
157
+ @dynamic_changes = Set.new # Rastreo manual para atributos dinámicos
239
158
  @persisted = false
240
-
241
- # === CAPTURA DE CONTEXTO ===
242
159
  @routing_key = self.class.thread_config(:routing_key)
243
160
  @exchange = self.class.thread_config(:exchange)
244
161
  @exchange_type = self.class.thread_config(:exchange_type)
245
162
 
246
- assign_attributes(attributes)
247
163
  super()
164
+ assign_attributes(attributes)
248
165
  end
249
166
 
250
- # Prioridad Routing Key: 1. Instancia (Capturada), 2. Clase
251
- def calculate_routing_key(id=nil)
252
- return @routing_key if @routing_key
253
- self.class.calculate_routing_key(id)
254
- end
255
-
256
- # Prioridad Exchange: 1. Instancia (Capturada), 2. Clase
257
- def current_exchange
258
- @exchange || self.class.current_exchange
167
+ # Limpia tanto el rastreo de ActiveModel como nuestro rastreo dinámico.
168
+ def clear_changes_information
169
+ super
170
+ @dynamic_changes.clear
259
171
  end
260
172
 
261
- # Prioridad Exchange Type: 1. Instancia (Capturada), 2. Clase
262
- def current_exchange_type
263
- @exchange_type || self.class.current_exchange_type
173
+ def attributes_for_serialization
174
+ @remote_attributes.merge(attributes)
264
175
  end
265
176
 
177
+ def calculate_routing_key(id=nil); @routing_key || self.class.calculate_routing_key(id); end
178
+ def current_exchange; @exchange || self.class.current_exchange; end
179
+ def current_exchange_type; @exchange_type || self.class.current_exchange_type; end
266
180
  def bug_bunny_client; self.class.bug_bunny_client; end
267
-
268
181
  def persisted?; !!@persisted; end
269
182
 
270
183
  def assign_attributes(new_attributes)
@@ -277,19 +190,36 @@ module BugBunny
277
190
  save
278
191
  end
279
192
 
280
- # Retorna solo los atributos que han cambiado.
193
+ # Retorna el hash combinado de cambios (Tipados + Dinámicos).
281
194
  def changes_to_send
282
- return changes.transform_values(&:last) unless changes.empty?
283
- @remote_attributes.except('id', 'ID', 'Id', '_id')
195
+ # 1. Cambios de ActiveModel (Tipados)
196
+ # changes returns { 'attr' => [old, new] } -> nos quedamos con new
197
+ payload = changes.transform_values(&:last)
198
+
199
+ # 2. Cambios Dinámicos (Manuales)
200
+ @dynamic_changes.each do |key|
201
+ payload[key] = @remote_attributes[key]
202
+ end
203
+
204
+ return payload unless payload.empty?
205
+
206
+ # Fallback: Si no hay cambios detectados, enviamos todo (útil para create)
207
+ attributes_for_serialization.except('id', 'ID', 'Id', '_id')
284
208
  end
285
209
 
286
- # Métodos mágicos para atributos.
210
+ # Intercepta asignaciones dinámicas y las marca como sucias.
287
211
  def method_missing(method_name, *args, &block)
288
212
  attribute_name = method_name.to_s
289
213
  if attribute_name.end_with?('=')
290
214
  key = attribute_name.chop
291
215
  val = args.first
292
- attribute_will_change!(key) unless @remote_attributes[key] == val
216
+
217
+ # Dirty Tracking Manual
218
+ # Si el valor cambia, lo marcamos en nuestro Set
219
+ if @remote_attributes[key] != val
220
+ @dynamic_changes << key
221
+ end
222
+
293
223
  @remote_attributes[key] = val
294
224
  else
295
225
  @remote_attributes.key?(attribute_name) ? @remote_attributes[attribute_name] : super
@@ -301,50 +231,45 @@ module BugBunny
301
231
  end
302
232
 
303
233
  def id
304
- @remote_attributes['id'] || @remote_attributes['ID'] || @remote_attributes['Id'] || @remote_attributes['_id']
234
+ attributes['id'] || @remote_attributes['id'] || @remote_attributes['ID'] || @remote_attributes['Id'] || @remote_attributes['_id']
305
235
  end
306
236
 
307
237
  def id=(value)
308
- @remote_attributes['id'] = value
238
+ if self.class.attribute_names.include?('id')
239
+ super(value)
240
+ else
241
+ @remote_attributes['id'] = value
242
+ end
309
243
  end
310
244
 
311
245
  def read_attribute_for_validation(attr)
312
- @remote_attributes[attr.to_s]
246
+ attr_s = attr.to_s
247
+ self.class.attribute_names.include?(attr_s) ? attribute(attr_s) : @remote_attributes[attr_s]
313
248
  end
314
249
 
315
- # @!group Persistencia RESTful
250
+ # @!group Persistencia
316
251
 
317
- # Guarda el registro.
318
- # Envía POST si es nuevo, PUT si ya existe.
319
- #
320
- # **AUTOMÁTICO:** Envuelve los parámetros en la clave del modelo (`param_key`).
321
- # Ej: Manager::Service -> "service". Esto facilita `params.require(:service)`.
322
- #
323
- # @return [Boolean] true si se guardó correctamente.
324
252
  def save
325
253
  return false unless valid?
326
254
 
327
255
  run_callbacks(:save) do
328
256
  is_new = !persisted?
329
257
  rk = calculate_routing_key(id)
330
-
331
- # 1. Obtenemos el payload plano (atributos modificados)
332
258
  flat_payload = changes_to_send
333
-
334
- # 2. Wrappeamos automáticamente en la clave del modelo
335
259
  key = self.class.param_key
336
260
  wrapped_payload = { key => flat_payload }
337
261
 
338
- # Mapeo a verbos HTTP
339
- if is_new
340
- # REST: POST resource (Create)
341
- path = self.class.resource_name
342
- response = bug_bunny_client.request(path, method: :post, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk, body: wrapped_payload)
343
- else
344
- # REST: PUT resource/id (Update)
345
- path = "#{self.class.resource_name}/#{id}"
346
- response = bug_bunny_client.request(path, method: :put, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk, body: wrapped_payload)
347
- end
262
+ path = is_new ? self.class.resource_name : "#{self.class.resource_name}/#{id}"
263
+ method = is_new ? :post : :put
264
+
265
+ response = bug_bunny_client.request(
266
+ path,
267
+ method: method,
268
+ exchange: current_exchange,
269
+ exchange_type: current_exchange_type,
270
+ routing_key: rk,
271
+ body: wrapped_payload
272
+ )
348
273
 
349
274
  handle_save_response(response)
350
275
  end
@@ -353,17 +278,12 @@ module BugBunny
353
278
  false
354
279
  end
355
280
 
356
- # Elimina el registro.
357
- # Envía DELETE resource/id.
358
281
  def destroy
359
282
  return false unless persisted?
360
-
361
283
  run_callbacks(:destroy) do
362
284
  path = "#{self.class.resource_name}/#{id}"
363
285
  rk = calculate_routing_key(id)
364
-
365
286
  bug_bunny_client.request(path, method: :delete, exchange: current_exchange, exchange_type: current_exchange_type, routing_key: rk)
366
-
367
287
  self.persisted = false
368
288
  end
369
289
  true
@@ -381,7 +301,6 @@ module BugBunny
381
301
  elsif response['status'] >= 400
382
302
  raise BugBunny::ClientError
383
303
  end
384
-
385
304
  assign_attributes(response['body'])
386
305
  self.persisted = true
387
306
  clear_changes_information
@@ -3,80 +3,101 @@
3
3
  module BugBunny
4
4
  # Clase interna que encapsula una unidad de trabajo sobre una conexión RabbitMQ.
5
5
  #
6
- # Su responsabilidad principal es gestionar el ciclo de vida de un `Bunny::Channel`.
7
- # En RabbitMQ, las conexiones TCP son costosas, pero los canales son ligeros.
8
- # Esta clase toma una conexión abierta del Pool, abre un canal exclusivo para esta sesión,
9
- # configura el QoS y facilita la creación de Exchanges y Colas.
6
+ # Gestiona el ciclo de vida de un `Bunny::Channel` implementando:
7
+ # 1. Carga Perezosa (Lazy Loading): El canal solo se abre al usarse.
8
+ # 2. Resiliencia: Intenta recuperar la conexión TCP si está cerrada.
10
9
  #
11
10
  # @api private
12
11
  class Session
13
- # Opciones por defecto para Exchanges: No durables, No auto-borrables.
12
+ # Opciones por defecto (Mantenemos las que tenías en tu repo)
14
13
  DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }.freeze
15
-
16
- # Opciones por defecto para Colas: No exclusivas, No durables, Auto-borrables.
17
- # @note Por defecto las colas son volátiles (`auto_delete: true`). Para workers persistentes,
18
- # se debe pasar explícitamente `durable: true, auto_delete: false`.
19
14
  DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: false, auto_delete: true }.freeze
20
15
 
21
16
  # @return [Bunny::Session] La conexión TCP subyacente.
22
17
  attr_reader :connection
23
18
 
24
- # @return [Bunny::Channel] El canal AMQP abierto para esta sesión.
25
- attr_reader :channel
19
+ # Inicializa una nueva sesión sin abrir canales todavía.
20
+ #
21
+ # @param connection [Bunny::Session] Una conexión (puede estar abierta o cerrada temporalmente).
22
+ def initialize(connection)
23
+ @connection = connection
24
+ @channel = nil
25
+ end
26
26
 
27
- # Inicializa una nueva sesión.
27
+ # Obtiene el canal actual o crea uno nuevo si es necesario.
28
28
  #
29
- # 1. Verifica que la conexión esté viva.
30
- # 2. Abre un nuevo canal.
31
- # 3. Habilita "Publisher Confirms" para garantizar que los mensajes lleguen al broker.
32
- # 4. Configura el "Prefetch" (QoS) global para este canal.
29
+ # Este método es el punto central de la robustez. Verifica la salud
30
+ # de la conexión y del canal antes de devolverlo.
33
31
  #
34
- # @param connection [Bunny::Session] Una conexión abierta.
35
- # @raise [BugBunny::Error] Si la conexión es nil o está cerrada.
36
- def initialize(connection)
37
- raise BugBunny::Error, "Connection is closed or nil" unless connection&.open?
32
+ # @return [Bunny::Channel] Un canal abierto y configurado.
33
+ # @raise [BugBunny::CommunicationError] Si no se puede restablecer la conexión.
34
+ def channel
35
+ # Si el canal existe y está abierto, lo devolvemos rápido.
36
+ return @channel if @channel&.open?
38
37
 
39
- @connection = connection
40
- # Creamos canal nuevo para esta sesión (Thread-safe dentro del contexto del Pool)
41
- @channel = connection.create_channel
42
- @channel.confirm_select
43
- @channel.prefetch(BugBunny.configuration.channel_prefetch)
38
+ # Si no, intentamos asegurar la conexión y crear el canal.
39
+ ensure_connection!
40
+ create_channel!
41
+
42
+ @channel
44
43
  end
45
44
 
46
45
  # Factory method para declarar o recuperar un Exchange.
46
+ # Usa el método robusto `channel` internamente.
47
47
  #
48
- # @param name [String, nil] El nombre del exchange. Si es nil/vacío, retorna el Default Exchange.
49
- # @param type [String, Symbol] El tipo de exchange (:direct, :topic, :fanout, :headers).
50
- # @param opts [Hash] Opciones de configuración (durable, auto_delete, arguments).
51
- # @return [Bunny::Exchange] La instancia del exchange.
48
+ # @param name [String, nil] Nombre del exchange.
49
+ # @param type [String, Symbol] Tipo de exchange.
50
+ # @param opts [Hash] Opciones adicionales.
52
51
  def exchange(name: nil, type: 'direct', opts: {})
53
52
  return channel.default_exchange if name.nil? || name.empty?
54
53
 
55
54
  merged_opts = DEFAULT_EXCHANGE_OPTIONS.merge(opts)
56
- case type.to_sym
57
- when :topic then channel.topic(name, merged_opts)
58
- when :direct then channel.direct(name, merged_opts)
59
- when :fanout then channel.fanout(name, merged_opts)
60
- when :headers then channel.headers(name, merged_opts)
61
- else channel.direct(name, merged_opts)
62
- end
55
+ # public_send permite llamar a :topic, :direct, etc. dinámicamente
56
+ channel.public_send(type, name, merged_opts)
63
57
  end
64
58
 
65
59
  # Factory method para declarar o recuperar una Cola.
60
+ # Usa el método robusto `channel` internamente.
66
61
  #
67
- # @param name [String] El nombre de la cola.
68
- # @param opts [Hash] Opciones de configuración (durable, auto_delete, exclusive, arguments).
69
- # @return [Bunny::Queue] La instancia de la cola.
62
+ # @param name [String] Nombre de la cola.
63
+ # @param opts [Hash] Opciones adicionales.
70
64
  def queue(name, opts = {})
71
65
  channel.queue(name.to_s, DEFAULT_QUEUE_OPTIONS.merge(opts))
72
66
  end
73
67
 
74
- # Cierra el canal asociado a esta sesión.
75
- # No cierra la conexión TCP (ya que esta pertenece al Pool), solo libera el canal virtual.
76
- #
77
- # @return [void]
68
+ # Cierra el canal asociado a esta sesión de forma segura.
78
69
  def close
79
- @channel.close if @channel&.open?
70
+ @channel&.close if @channel&.open?
71
+ @channel = nil
72
+ end
73
+
74
+ private
75
+
76
+ # Crea y configura un nuevo canal.
77
+ # Asume que la conexión ya ha sido verificada por `ensure_connection!`.
78
+ def create_channel!
79
+ @channel = @connection.create_channel
80
+
81
+ # Configuraciones globales de BugBunny
82
+ @channel.confirm_select
83
+
84
+ if BugBunny.configuration.channel_prefetch
85
+ @channel.prefetch(BugBunny.configuration.channel_prefetch)
86
+ end
87
+ rescue StandardError => e
88
+ raise BugBunny::CommunicationError, "Failed to create channel: #{e.message}"
89
+ end
90
+
91
+ # Garantiza que la conexión TCP esté abierta.
92
+ # Si está cerrada, intenta reconectarla (Reconexión Transparente).
93
+ def ensure_connection!
94
+ return if @connection.open?
95
+
96
+ BugBunny.configuration.logger.warn("[BugBunny] Connection lost. Attempting to reconnect...")
97
+ @connection.start
98
+ rescue StandardError => e
99
+ BugBunny.configuration.logger.error("[BugBunny] Critical connection failure: #{e.message}")
100
+ raise BugBunny::CommunicationError, "Could not reconnect to RabbitMQ: #{e.message}"
80
101
  end
81
102
  end
82
103
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = "3.0.6"
4
+ VERSION = "3.1.0"
5
5
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../test_helper'
4
+ require 'connection_pool'
5
+
6
+ class FireAndForgetTest < Minitest::Test
7
+ def setup
8
+ skip "RabbitMQ no disponible" unless TestHelper.rabbitmq_available?
9
+
10
+ # 1. Configuración
11
+ BugBunny.configure do |c|
12
+ c.host = 'localhost'
13
+ c.username = 'wisproMQ'
14
+ c.password = 'wisproMQ'
15
+ c.port = 5672
16
+ c.logger = Logger.new(nil)
17
+ end
18
+
19
+ @pool = ConnectionPool.new(size: 1, timeout: 5) { BugBunny.create_connection }
20
+
21
+ # 2. Infraestructura de Test
22
+ @queue_name = 'test_fire_queue'
23
+ @exchange_name = 'test_fire_exchange'
24
+ @routing_key = 'logs.error'
25
+
26
+ # Usamos una Queue de Ruby para pasar el mensaje del Consumer al Test
27
+ @message_bucket = Queue.new
28
+
29
+ # 3. Consumidor "Espía"
30
+ @conn_consumer = BugBunny.create_connection
31
+ @consumer_thread = Thread.new do
32
+ ch = @conn_consumer.create_channel
33
+ # IMPORTANTE: Aquí declaramos el exchange como TOPIC
34
+ x = ch.topic(@exchange_name)
35
+ q = ch.queue(@queue_name).bind(x, routing_key: @routing_key)
36
+
37
+ q.subscribe(block: true) do |delivery_info, properties, body|
38
+ @message_bucket << {
39
+ body: body,
40
+ routing_key: delivery_info.routing_key
41
+ }
42
+ end
43
+ end
44
+ sleep 0.5 # Wait boot
45
+ end
46
+
47
+ def teardown
48
+ return unless @conn_consumer
49
+ @conn_consumer.close
50
+ @consumer_thread.kill
51
+ end
52
+
53
+ def test_publish_directly
54
+ client = BugBunny::Client.new(pool: @pool)
55
+ payload = { system: 'payment', error: 'timeout' }
56
+
57
+ # 1. Disparamos (Fire)
58
+ client.publish(
59
+ 'logs/error',
60
+ body: payload,
61
+ exchange: @exchange_name,
62
+ exchange_type: 'topic',
63
+ routing_key: @routing_key
64
+ )
65
+
66
+ # 2. Verificamos asíncronamente
67
+ received = nil
68
+ Timeout.timeout(2) do
69
+ received = @message_bucket.pop
70
+ end
71
+
72
+ # 3. Aserciones
73
+ assert_equal payload.to_json, received[:body]
74
+ assert_equal @routing_key, received[:routing_key]
75
+ end
76
+ end