bug_bunny 4.11.1 → 4.13.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.
data/skill/SKILL.md CHANGED
@@ -15,6 +15,9 @@ Skill de conocimiento completo sobre BugBunny. Consultame para cualquier pregunt
15
15
  **Session** — `BugBunny::Session` envuelve canales de Bunny con thread-safety y double-checked locking.
16
16
  **RPC** — Patrón síncrono que usa la pseudo-cola `amq.rabbitmq.reply-to` para respuestas sin crear queues temporales.
17
17
  **Fire-and-Forget** — Patrón asíncrono donde el producer publica y continúa sin esperar respuesta. Retorna `{ 'status' => 202 }`.
18
+ **Publisher Confirms** — Extensión de RabbitMQ que confirma al publisher que el broker recibió el mensaje. BugBunny lo expone como `confirmed: true` en `Client#publish`: el publish bloquea hasta `wait_for_confirms`. NACK del broker (rechazo explícito) levanta `BugBunny::PublishNacked` por default; configurable via `config.nack_raise = false` o `nack_raise: false` per request para volver al modo log-only.
19
+ **Mandatory** — Flag de `basic.publish` que pide al broker retornar el mensaje al publisher si no es ruteable a ninguna cola. Se procesa via `basic.return` (no es respuesta del request).
20
+ **basic.return** — Evento asincrónico que Bunny dispatcha por exchange (`Bunny::Exchange#on_return`). BugBunny registra un handler único por nombre de exchange al resolverlo en `Session#exchange` y lo delega a `Configuration#on_return` o al default que logea.
18
21
  **Resource** — ORM tipo ActiveRecord que mapea operaciones CRUD a llamadas AMQP.
19
22
  **Consumer** — Worker bloqueante que despacha mensajes a controladores mediante un Router.
20
23
  **Connection Pool** — Pool de conexiones (`connection_pool` gem) que comparte sessions entre threads. Cada slot cachea su `Session` y `Producer`.
@@ -59,7 +62,7 @@ Skill de conocimiento completo sobre BugBunny. Consultame para cualquier pregunt
59
62
  |---|---|
60
63
  | `BugBunny::Configuration` | Configuración global. Valida campos requeridos en `BugBunny.configure`. |
61
64
  | `BugBunny::Session` | Wrapper de canal Bunny. Declara exchanges y queues. Thread-safe con double-checked locking. |
62
- | `BugBunny::Producer` | Publica mensajes. Implementa RPC con `Concurrent::IVar` y direct reply-to. |
65
+ | `BugBunny::Producer` | Publica mensajes. Implementa tres modos: `#fire` (async), `#confirmed` (sync con `wait_for_confirms`) y `#rpc` (direct reply-to + `Concurrent::IVar`). |
63
66
  | `BugBunny::Client` | API de alto nivel. Pool de conexiones y middleware stack (onion architecture). |
64
67
  | `BugBunny::Consumer` | Subscribe loop con health check. Rutea mensajes via `BugBunny.routes`. |
65
68
  | `BugBunny::ConsumerMiddleware::Stack` | Pipeline de middlewares antes de `process_message`. Thread-safe. |
@@ -126,6 +129,11 @@ BugBunny.configure do |config|
126
129
  config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.id } }
127
130
  config.on_rpc_reply = ->(h) { Tracer.hydrate(h['X-Trace-Id']) }
128
131
 
132
+ # Publisher Confirms — handler global para basic.return (mensajes mandatory no ruteados).
133
+ # Si es nil, BugBunny logea como `session.broker_return` con nivel :warn.
134
+ # Firma: ->(return_info, properties, body)
135
+ config.on_return = ->(ri, _props, body) { MyAlerts.unroutable(rk: ri.routing_key, body: body) }
136
+
129
137
  # Infraestructura (cascade level 2)
130
138
  config.exchange_options = { durable: true }
131
139
  config.queue_options = { auto_delete: false }
@@ -169,7 +177,13 @@ Genera rutas REST estándar (index, show, create, update, destroy) mapeadas a co
169
177
 
170
178
  ---
171
179
 
172
- ## API: RPC vs Fire-and-Forget
180
+ ## API: Modos de Entrega
181
+
182
+ | Modo | Espera | Uso | Método |
183
+ |---|---|---|---|
184
+ | `:rpc` | Reply del consumer remoto | Request-response síncrono | `client.request(...)` |
185
+ | `:publish` | Nada | Logs, eventos best-effort | `client.publish(...)` |
186
+ | `:confirmed` | ACK del broker (`wait_for_confirms`) | Auditoría, billing, eventos críticos | `client.publish(..., confirmed: true)` |
173
187
 
174
188
  **RPC síncrono** — Bloquea hasta respuesta. Usa `amq.rabbitmq.reply-to`. Timeout configurable.
175
189
  ```ruby
@@ -183,6 +197,19 @@ client.publish('events', method: :post, body: { type: 'order.placed' })
183
197
  # → { 'status' => 202, 'body' => nil }
184
198
  ```
185
199
 
200
+ **Confirmed (Publisher Confirms)** — Bloquea hasta el ACK del broker. Garantía de entrega al broker (no al consumer remoto).
201
+ ```ruby
202
+ client.publish('acct.start', exchange: 'acct_x', body: payload,
203
+ confirmed: true, mandatory: true, confirm_timeout: 0.5)
204
+ # → { 'status' => 202, 'body' => nil } # broker ACK confirmado
205
+ # Si broker NACK: logea `producer.confirms_nacked` y raise BugBunny::PublishNacked
206
+ # (default — opt-out con config.nack_raise = false o nack_raise: false per request).
207
+ # Si timeout: raise BugBunny::RequestTimeout.
208
+ # Si mandatory + no ruteable: dispara `Configuration#on_return` (default: logea `session.broker_return`).
209
+ ```
210
+
211
+ `Bunny::Channel#wait_for_confirms` no soporta timeout nativo en Bunny 2.x. BugBunny lo implementa lanzando la espera en un hilo auxiliar y usando `Concurrent::IVar#value(timeout)` como reloj.
212
+
186
213
  ---
187
214
 
188
215
  ## FAQ
@@ -7,7 +7,7 @@ API de alto nivel para publicar mensajes. Usa un pool de conexiones y una pila d
7
7
  ### Métodos Principales
8
8
 
9
9
  ```ruby
10
- # RPC síncrono — bloquea hasta respuesta
10
+ # RPC síncrono — bloquea hasta respuesta del consumer
11
11
  response = client.request('users/123', method: :get, timeout: 30)
12
12
  response = client.request('users', method: :post, body: { name: 'John' })
13
13
  # → { 'status' => 200, 'body' => {...} }
@@ -16,8 +16,17 @@ response = client.request('users', method: :post, body: { name: 'John' })
16
16
  client.publish('events/user_created', method: :post, body: { user_id: 42 })
17
17
  # → { 'status' => 202, 'body' => nil }
18
18
 
19
- # General con bloque
20
- client.send(url) { |req| req.delivery_mode = :publish }
19
+ # Publisher Confirms — bloquea hasta ACK del broker (no del consumer)
20
+ client.publish('acct.start', exchange: 'acct_x', body: payload,
21
+ confirmed: true, mandatory: true, confirm_timeout: 0.5)
22
+ # → { 'status' => 202, 'body' => nil }
23
+
24
+ # General con bloque — máximo control
25
+ client.send(url) do |req|
26
+ req.delivery_mode = :confirmed
27
+ req.mandatory = true
28
+ req.confirm_timeout = 1.0
29
+ end
21
30
  ```
22
31
 
23
32
  ### Argumentos de Request
@@ -34,12 +43,16 @@ client.send(url) { |req| req.delivery_mode = :publish }
34
43
  | `routing_key` | String | path | Override de routing key |
35
44
  | `exchange_options` | Hash | {} | Cascade level 3 |
36
45
  | `queue_options` | Hash | {} | Cascade level 3 |
46
+ | `confirmed` | Boolean | `false` | En `Client#publish`, flipea `delivery_mode` a `:confirmed`. Bloquea hasta `wait_for_confirms`. |
47
+ | `mandatory` | Boolean | `false` | Pide al broker retornar el mensaje si no es ruteable. Solo útil con `confirmed: true`. |
48
+ | `confirm_timeout` | Float | `nil` | Segundos máximos a esperar el ACK. `nil` = espera indefinida. Excedido → `BugBunny::RequestTimeout`. |
49
+ | `nack_raise` | Boolean | `nil` | Override per-request de `config.nack_raise`. `nil` = usa flag global. |
37
50
 
38
51
  ## Producer (bajo nivel)
39
52
 
40
- El `Producer` es usado internamente por el `Client`. Implementa los dos patrones de entrega.
53
+ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de entrega.
41
54
 
42
- ### RPC
55
+ ### RPC (`Producer#rpc`)
43
56
 
44
57
  - Usa `amq.rabbitmq.reply-to` (direct reply-to pattern).
45
58
  - Tracking de `correlation_id` en `Concurrent::Map` (thread-safe).
@@ -47,11 +60,19 @@ El `Producer` es usado internamente por el `Client`. Implementa los dos patrones
47
60
  - Double-checked locking mutex para seguridad del listener.
48
61
  - Timeout lanza `BugBunny::RequestTimeout`.
49
62
 
50
- ### Fire-and-Forget
63
+ ### Fire-and-Forget (`Producer#fire`)
51
64
 
52
65
  - Publica en el exchange y retorna `{ 'status' => 202 }` inmediatamente.
53
66
  - Sin confirmación de procesamiento.
54
67
 
68
+ ### Confirmed (`Producer#confirmed`)
69
+
70
+ - Publica y bloquea hasta `channel.wait_for_confirms` del broker.
71
+ - Bunny 2.x **no soporta timeout** nativo en `wait_for_confirms` — BugBunny envuelve la llamada en un hilo auxiliar y usa `Concurrent::IVar#value(timeout)` como reloj. Si `confirm_timeout` expira → `BugBunny::RequestTimeout`.
72
+ - Si `wait_for_confirms` devuelve `false` (broker NACKea), se logea `producer.confirms_nacked` con `count` y `path`. Por default (`config.nack_raise = true`) levanta `BugBunny::PublishNacked` con `path` y `nacked_count`. Para opt-out: `config.nack_raise = false` o pasar `nack_raise: false` per request — en ese caso solo logea y retorna 202.
73
+ - Si `mandatory: true` y el mensaje no es ruteable, el broker dispara `basic.return`. El handler se atacha vía `Bunny::Exchange#on_return` en `Session#exchange` la primera vez que se resuelve cada exchange (cacheado por nombre, una sola vez por canal) y delega a `Configuration#on_return` o al logger por default.
74
+ - Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
75
+
55
76
  ## Middleware Stack (Client-side, Onion Architecture)
56
77
 
57
78
  ```
@@ -135,15 +156,78 @@ req.body # Hash, Array, String o nil
135
156
  req.headers # Hash custom
136
157
  req.params # Hash query string
137
158
  req.full_path # path + query string
138
- req.delivery_mode # :rpc o :publish
159
+ req.delivery_mode # :rpc, :publish o :confirmed
139
160
  req.exchange # String destino
140
161
  req.exchange_type # 'direct', 'topic', 'fanout'
141
162
  req.correlation_id # UUID auto-generado
142
163
  req.reply_to # 'amq.rabbitmq.reply-to' (auto para RPC)
143
164
  req.timestamp # Time.now.to_i
144
165
  req.content_type # 'application/json'
166
+ req.mandatory # Boolean — solo modo :confirmed
167
+ req.confirm_timeout # Float|nil — solo modo :confirmed
168
+ req.nack_raise # Boolean|nil — override per-request de config.nack_raise (solo modo :confirmed)
145
169
  ```
146
170
 
171
+ Cuando `mandatory == true`, `Request#amqp_options` inyecta `mandatory: true` en el hash que va a `basic_publish`.
172
+
173
+ ## Publisher Confirms y `basic.return`
174
+
175
+ ### Flujo
176
+
177
+ ```
178
+ client.publish('x', confirmed: true, mandatory: true)
179
+
180
+
181
+ Producer#confirmed
182
+
183
+ ├──> publish_message (exchange.publish con mandatory: true)
184
+
185
+ ├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
186
+
187
+ └──> handle_confirm_result
188
+ ├─ acked == true → return { status: 202 }
189
+ └─ acked == false → log WARN producer.confirms_nacked
190
+ └─ raise BugBunny::PublishNacked (si config.nack_raise || req.nack_raise)
191
+
192
+ Asíncronamente, si el broker no pudo rutear:
193
+ broker ──basic.return──> Exchange#on_return ──> Session handler ──> Configuration#on_return
194
+ └──> default: log session.broker_return WARN
195
+ ```
196
+
197
+ ### `Configuration#on_return`
198
+
199
+ El handler se registra **una sola vez por exchange** en `Session#exchange` (cuando `publisher_confirms: true`) usando `Bunny::Exchange#on_return`. Bunny dispatcha `basic.return` por exchange, no por canal, así que el handler vive en cada `Bunny::Exchange` resuelto vía cascada. `Session` cachea los exchanges ya configurados por nombre en `@configured_returns` para no re-registrar en cada publish; el set se limpia al recrear el canal.
200
+
201
+ ```ruby
202
+ BugBunny.configure do |c|
203
+ c.on_return = ->(return_info, properties, body) {
204
+ # return_info: Bunny::ReturnInfo (reply_code, reply_text, exchange, routing_key)
205
+ # properties: Bunny::MessageProperties
206
+ # body: String (payload crudo)
207
+ MyAlerts.unroutable(rk: return_info.routing_key, body: body)
208
+ }
209
+ end
210
+ ```
211
+
212
+ Si `on_return` es `nil` (default), BugBunny logea:
213
+
214
+ ```
215
+ component=bug_bunny event=session.broker_return reply_code=312
216
+ reply_text="NO_ROUTE" exchange=evt_x routing_key=acct.start body_size=64
217
+ ```
218
+
219
+ Excepciones del callback se capturan y se logean como `session.on_return_failed` para no romper el hilo I/O de Bunny.
220
+
221
+ ### Cuándo usar `:confirmed`
222
+
223
+ | Escenario | Modo recomendado |
224
+ |---|---|
225
+ | Logs, eventos best-effort | `:publish` |
226
+ | Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
227
+ | Request-response síncrono | `:rpc` |
228
+
229
+ `:confirmed` cuesta un round-trip al broker pero **no** al consumer remoto — más rápido que RPC, con garantía de entrega al broker. NACK del broker es raro (típicamente por confirm policies internas, disk full, replicación insuficiente) pero **sí** implica que el mensaje no fue aceptado. Por default BugBunny levanta `BugBunny::PublishNacked` para que el caller pueda escalar (ej: convertir a HTTP 503 y dejar que el sistema upstream reintente). Opt-out con `config.nack_raise = false` o `nack_raise: false` per request.
230
+
147
231
  ## Cascada de Configuración (3 niveles)
148
232
 
149
233
  ```ruby
@@ -8,6 +8,7 @@ StandardError
8
8
  ├── BugBunny::CommunicationError
9
9
  ├── BugBunny::ConfigurationError
10
10
  ├── BugBunny::SecurityError
11
+ ├── BugBunny::PublishNacked
11
12
  ├── BugBunny::ClientError (4xx)
12
13
  │ ├── BugBunny::BadRequest (400)
13
14
  │ ├── BugBunny::NotFound (404)
@@ -37,6 +38,12 @@ StandardError
37
38
  **Cuándo:** El consumer resuelve la clase pero falla la validación `is_a?(BugBunny::Controller)`.
38
39
  **Resolución:** Verificar que el controlador herede de `BugBunny::Controller` y que `config.controller_namespace` sea correcto.
39
40
 
41
+ ### BugBunny::PublishNacked
42
+ **Causa:** El broker NACKea explícitamente una publicación en modo `:confirmed` (`Client#publish(..., confirmed: true)`). Indica que el mensaje no fue aceptado — disk full, replicación insuficiente, confirm policy interna, etc.
43
+ **Cuándo:** `Producer#confirmed` detecta `wait_for_confirms == false`. Se levanta por default (`config.nack_raise = true`).
44
+ **Atributos:** `path` (ruta del request) y `nacked_count` (cantidad de delivery tags NACKeados).
45
+ **Resolución:** Para casos críticos (auditoría, billing, RADIUS accounting), dejar que la excepción bubble para que el caller upstream reintente (ej: HTTP 503 → retry). Para tolerar NACKs (eventos best-effort), `BugBunny.configuration.nack_raise = false` global o `nack_raise: false` per request — en ese caso solo se logea `producer.confirms_nacked`.
46
+
40
47
  ## Errores de Cliente (4xx)
41
48
 
42
49
  ### BugBunny::BadRequest (400)
data/skills.yml CHANGED
@@ -2,43 +2,28 @@ mcps:
2
2
  - github
3
3
  - clickup
4
4
  skills:
5
- skill-manager:
6
- repo: sequre/ai_knowledge
7
- scope: local
8
5
  yard:
9
6
  repo: sequre/ai_knowledge
10
- scope: local
11
7
  quality-code:
12
8
  repo: sequre/ai_knowledge
13
- scope: local
14
9
  gem-release:
15
10
  repo: sequre/ai_knowledge
16
- scope: local
17
11
  skill-builder:
18
12
  repo: sequre/ai_knowledge
19
- scope: local
20
- ai-reports:
13
+ matrix-element:
21
14
  repo: sequre/ai_knowledge
22
- scope: local
23
15
  environment:
24
- space_id: "${AI_REPORTS_SPACE_ID}"
25
- bug_reports_list_id: "${AI_REPORTS_BUG_REPORTS_LIST_ID}"
26
- improvements_list_id: "${AI_REPORTS_IMPROVEMENTS_LIST_ID}"
27
- agent-review:
16
+ homeserver: "https://matrix.cloud.wispro.co"
17
+ auth_token: "${MATRIX_AUTH_TOKEN}"
18
+ rooms:
19
+ agents: "!VCHwQXgmXdyhhhPhoz:matrix.cloud.wispro.co"
20
+ skill-feedback:
21
+ repo: sequre/ai_knowledge
22
+ agent-issue:
28
23
  repo: sequre/ai_knowledge
29
- scope: local
30
- environment:
31
- space_id: "${AGENT_REVIEW_SAPCE_ID}"
32
- list_id: "${AGENT_LIST_ID}"
33
24
  documentation-writer:
34
25
  repo: github/awesome-copilot
35
26
  path: skills/documentation-writer
36
- scope: local
37
- find-skills:
38
- repo: vercel-labs/skills
39
- path: skills/find-skills
40
- scope: local
41
27
  rabbitmq-expert:
42
28
  repo: martinholovsky/claude-skills-generator
43
29
  path: skills/rabbitmq-expert
44
- scope: local
@@ -3,11 +3,47 @@
3
3
  # Stubs livianos de Bunny para specs unitarios que no necesitan RabbitMQ real.
4
4
 
5
5
  module BunnyMocks
6
+ # Stub mínimo de `Bunny::Exchange`: cachea el bloque pasado a `on_return`
7
+ # (Bunny lo invoca al recibir un `basic.return` del broker) y permite que
8
+ # los specs disparen retornos sintéticos vía `fire_return`.
9
+ class FakeExchange
10
+ attr_reader :name, :type, :opts
11
+
12
+ def initialize(name, type, opts = {})
13
+ @name = name
14
+ @type = type
15
+ @opts = opts
16
+ end
17
+
18
+ def on_return(&block)
19
+ @on_return_handler = block
20
+ end
21
+
22
+ def publish(_payload, _opts = {}); end
23
+
24
+ def fire_return(return_info, properties, body)
25
+ @on_return_handler&.call(return_info, properties, body)
26
+ end
27
+ end
28
+
6
29
  FakeChannel = Struct.new(:open) do
7
30
  def open? = open
8
31
  def close = (self.open = false)
9
32
  def confirm_select; end
10
33
  def prefetch(_n); end
34
+
35
+ def topic(name, opts = {}) = exchange_for(name, 'topic', opts)
36
+ def direct(name, opts = {}) = exchange_for(name, 'direct', opts)
37
+ def fanout(name, opts = {}) = exchange_for(name, 'fanout', opts)
38
+ def headers(name, opts = {}) = exchange_for(name, 'headers', opts)
39
+ def default_exchange = exchange_for('', 'direct', {})
40
+
41
+ private
42
+
43
+ def exchange_for(name, type, opts)
44
+ @exchanges ||= {}
45
+ @exchanges[name] ||= FakeExchange.new(name, type, opts)
46
+ end
11
47
  end
12
48
 
13
49
  FakeConnection = Struct.new(:open, :channel_to_return) do
@@ -27,7 +27,7 @@ RSpec.describe BugBunny::Client, 'session pooling' do
27
27
  def client_with_pool(pool)
28
28
  client = described_class.new(pool: pool)
29
29
  # Stub Producer#rpc para que no toque RabbitMQ real
30
- allow_any_instance_of(BugBunny::Producer).to receive(:rpc) do |_prod, req|
30
+ allow_any_instance_of(BugBunny::Producer).to receive(:rpc) do |_prod, _req|
31
31
  { 'status' => 200, 'body' => '{"ok":true}' }
32
32
  end
33
33
  allow_any_instance_of(BugBunny::Producer).to receive(:fire) do |_prod, _req|
@@ -115,7 +115,7 @@ RSpec.describe BugBunny::Client, 'session pooling' do
115
115
 
116
116
  describe 'thread-safety' do
117
117
  it 'múltiples threads con la misma conexión no generan Sessions duplicadas' do
118
- conn = fake_conn
118
+ conn = fake_conn
119
119
  # Pool siempre devuelve la misma conexión — simula concurrencia en el mismo slot
120
120
  pool = Object.new
121
121
  mutex = Mutex.new
@@ -138,6 +138,83 @@ RSpec.describe BugBunny::Client, 'session pooling' do
138
138
  end
139
139
  end
140
140
 
141
+ describe 'Delivery mode routing' do
142
+ def fake_conn_local
143
+ channel = BunnyMocks::FakeChannel.new(true)
144
+ BunnyMocks::FakeConnection.new(true, channel)
145
+ end
146
+
147
+ it 'publish con confirmed: true enruta a Producer#confirmed' do
148
+ conn = fake_conn_local
149
+ client = described_class.new(pool: fake_pool(conn))
150
+
151
+ confirmed_called = false
152
+ allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, _req|
153
+ confirmed_called = true
154
+ { 'status' => 202, 'body' => nil }
155
+ end
156
+
157
+ client.publish('acct.start',
158
+ exchange: 'x', exchange_type: 'direct', body: { a: 1 },
159
+ confirmed: true)
160
+
161
+ expect(confirmed_called).to be(true)
162
+ end
163
+
164
+ it 'propaga mandatory y confirm_timeout al Request' do
165
+ conn = fake_conn_local
166
+ client = described_class.new(pool: fake_pool(conn))
167
+
168
+ captured = nil
169
+ allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, req|
170
+ captured = req
171
+ { 'status' => 202, 'body' => nil }
172
+ end
173
+
174
+ client.publish('acct.start',
175
+ exchange: 'x', exchange_type: 'direct',
176
+ confirmed: true, mandatory: true, confirm_timeout: 0.5)
177
+
178
+ expect(captured.delivery_mode).to eq(:confirmed)
179
+ expect(captured.mandatory).to be(true)
180
+ expect(captured.confirm_timeout).to eq(0.5)
181
+ end
182
+
183
+ it 'publish sin confirmed: true sigue invocando #fire (backward compat)' do
184
+ conn = fake_conn_local
185
+ client = described_class.new(pool: fake_pool(conn))
186
+
187
+ fire_called = false
188
+ allow_any_instance_of(BugBunny::Producer).to receive(:fire) do |_prod, _req|
189
+ fire_called = true
190
+ { 'status' => 202, 'body' => nil }
191
+ end
192
+
193
+ client.publish('evt', exchange: 'x', exchange_type: 'direct', body: {})
194
+
195
+ expect(fire_called).to be(true)
196
+ end
197
+
198
+ it 'send con bloque permite setear delivery_mode = :confirmed' do
199
+ conn = fake_conn_local
200
+ client = described_class.new(pool: fake_pool(conn))
201
+
202
+ confirmed_called = false
203
+ allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, _req|
204
+ confirmed_called = true
205
+ { 'status' => 202, 'body' => nil }
206
+ end
207
+
208
+ client.send('evt.x', exchange: 'x', exchange_type: 'direct') do |req|
209
+ req.delivery_mode = :confirmed
210
+ req.mandatory = true
211
+ req.confirm_timeout = 0.2
212
+ end
213
+
214
+ expect(confirmed_called).to be(true)
215
+ end
216
+ end
217
+
141
218
  describe 'Session no se cierra entre requests' do
142
219
  it 'no invoca close en la Session al terminar el request' do
143
220
  conn = fake_conn
@@ -149,6 +149,31 @@ RSpec.describe BugBunny::Configuration do
149
149
  end
150
150
  end
151
151
 
152
+ describe 'on_return callback' do
153
+ it 'tiene default nil' do
154
+ expect(BugBunny::Configuration.new.on_return).to be_nil
155
+ end
156
+
157
+ it 'acepta un Proc' do
158
+ cb = ->(_, _, _) { :noop }
159
+ configure_with(on_return: cb)
160
+
161
+ expect(BugBunny.configuration.on_return).to be(cb)
162
+ end
163
+ end
164
+
165
+ describe 'nack_raise flag' do
166
+ it 'tiene default true (raise PublishNacked en NACK)' do
167
+ expect(BugBunny::Configuration.new.nack_raise).to be(true)
168
+ end
169
+
170
+ it 'acepta false para opt-out (modo legacy)' do
171
+ configure_with(nack_raise: false)
172
+
173
+ expect(BugBunny.configuration.nack_raise).to be(false)
174
+ end
175
+ end
176
+
152
177
  describe '.validate! directamente' do
153
178
  it 'es invocable directamente sobre la instancia' do
154
179
  config = BugBunny::Configuration.new
@@ -184,4 +184,146 @@ RSpec.describe BugBunny::Producer do
184
184
  end
185
185
  end
186
186
  end
187
+
188
+ describe '#confirmed' do
189
+ let(:fake_exchange) { double('exchange') }
190
+
191
+ let(:mock_channel) do
192
+ ch = double('channel')
193
+ allow(ch).to receive(:publish)
194
+ allow(ch).to receive(:open?).and_return(true)
195
+ allow(ch).to receive(:wait_for_confirms).and_return(true)
196
+ allow(ch).to receive(:nacked_set).and_return(Set.new)
197
+ ch
198
+ end
199
+
200
+ let(:mock_session) do
201
+ s = instance_double(BugBunny::Session)
202
+ allow(s).to receive(:exchange).and_return(fake_exchange)
203
+ allow(s).to receive(:channel).and_return(mock_channel)
204
+ s
205
+ end
206
+
207
+ let(:confirmed_producer) { described_class.new(mock_session) }
208
+
209
+ before do
210
+ allow(confirmed_producer).to receive(:safe_log) do |level, event, **kwargs|
211
+ logged_events << { level: level, event: event, kwargs: kwargs }
212
+ end
213
+ allow(fake_exchange).to receive(:publish)
214
+ end
215
+
216
+ def build_request
217
+ req = BugBunny::Request.new('acct.start')
218
+ req.exchange = 'acct_x'
219
+ req.method = :post
220
+ req.body = { tenant: 42 }
221
+ req
222
+ end
223
+
224
+ it 'retorna { status: 202 } cuando el broker confirma' do
225
+ result = confirmed_producer.confirmed(build_request)
226
+
227
+ expect(result).to eq('status' => 202, 'body' => nil)
228
+ end
229
+
230
+ it 'invoca wait_for_confirms en el canal' do
231
+ confirmed_producer.confirmed(build_request)
232
+
233
+ expect(mock_channel).to have_received(:wait_for_confirms)
234
+ end
235
+
236
+ it 'publica con mandatory: true cuando el request lo activa' do
237
+ req = build_request
238
+ req.mandatory = true
239
+
240
+ confirmed_producer.confirmed(req)
241
+
242
+ expect(fake_exchange).to have_received(:publish).with(
243
+ anything,
244
+ hash_including(mandatory: true, routing_key: 'acct.start')
245
+ )
246
+ end
247
+
248
+ it 'NO logea producer.confirms_nacked cuando wait_for_confirms devuelve true' do
249
+ confirmed_producer.confirmed(build_request)
250
+
251
+ nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
252
+ expect(nack_event).to be_nil
253
+ end
254
+
255
+ context 'cuando el broker NACKea (wait_for_confirms devuelve false)' do
256
+ before do
257
+ allow(mock_channel).to receive(:wait_for_confirms).and_return(false)
258
+ allow(mock_channel).to receive(:nacked_set).and_return(Set.new([1, 2]))
259
+ end
260
+
261
+ it 'levanta BugBunny::PublishNacked por default (config.nack_raise = true)' do
262
+ expect { confirmed_producer.confirmed(build_request) }.to raise_error(BugBunny::PublishNacked) do |err|
263
+ expect(err.path).to eq('acct.start')
264
+ expect(err.nacked_count).to eq(2)
265
+ end
266
+ end
267
+
268
+ it 'logea producer.confirms_nacked antes de levantar' do
269
+ expect { confirmed_producer.confirmed(build_request) }.to raise_error(BugBunny::PublishNacked)
270
+
271
+ nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
272
+ expect(nack_event).not_to be_nil
273
+ expect(nack_event[:kwargs]).to include(count: 2, path: 'acct.start')
274
+ end
275
+
276
+ it 'no levanta si el request override `nack_raise = false`' do
277
+ req = build_request
278
+ req.nack_raise = false
279
+
280
+ result = confirmed_producer.confirmed(req)
281
+
282
+ expect(result).to eq('status' => 202, 'body' => nil)
283
+ expect(logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }).not_to be_nil
284
+ end
285
+
286
+ it 'no levanta si la configuración global tiene `nack_raise = false`' do
287
+ allow(BugBunny.configuration).to receive(:nack_raise).and_return(false)
288
+
289
+ result = confirmed_producer.confirmed(build_request)
290
+
291
+ expect(result).to eq('status' => 202, 'body' => nil)
292
+ end
293
+
294
+ it 'el override per-request gana sobre la configuración global' do
295
+ allow(BugBunny.configuration).to receive(:nack_raise).and_return(false)
296
+ req = build_request
297
+ req.nack_raise = true
298
+
299
+ expect { confirmed_producer.confirmed(req) }.to raise_error(BugBunny::PublishNacked)
300
+ end
301
+ end
302
+
303
+ it 'levanta BugBunny::RequestTimeout si wait_for_confirms excede confirm_timeout' do
304
+ allow(mock_channel).to receive(:wait_for_confirms) {
305
+ sleep 1
306
+ true
307
+ }
308
+
309
+ req = build_request
310
+ req.confirm_timeout = 0.05
311
+
312
+ expect { confirmed_producer.confirmed(req) }.to raise_error(BugBunny::RequestTimeout, /Timeout/)
313
+ end
314
+
315
+ it 'envuelve errores del canal como BugBunny::CommunicationError' do
316
+ allow(mock_channel).to receive(:wait_for_confirms).and_raise(StandardError, 'boom')
317
+
318
+ expect { confirmed_producer.confirmed(build_request) }
319
+ .to raise_error(BugBunny::CommunicationError, /boom/)
320
+ end
321
+
322
+ it 'propaga BugBunny::Error sin envolver' do
323
+ allow(fake_exchange).to receive(:publish).and_raise(BugBunny::CommunicationError, 'chan dead')
324
+
325
+ expect { confirmed_producer.confirmed(build_request) }
326
+ .to raise_error(BugBunny::CommunicationError, 'chan dead')
327
+ end
328
+ end
187
329
  end
@@ -47,5 +47,34 @@ RSpec.describe BugBunny::Request do
47
47
 
48
48
  expect(request.amqp_options[:headers]['x-http-method']).to eq('POST')
49
49
  end
50
+
51
+ it 'omite :mandatory cuando no fue activado' do
52
+ expect(request.amqp_options).not_to have_key(:mandatory)
53
+ end
54
+
55
+ it 'incluye :mandatory => true cuando se activa' do
56
+ request.mandatory = true
57
+
58
+ expect(request.amqp_options[:mandatory]).to be(true)
59
+ end
60
+ end
61
+
62
+ describe 'Publisher Confirms attributes' do
63
+ it 'tiene mandatory=false y confirm_timeout=nil por defecto' do
64
+ req = described_class.new('foo')
65
+
66
+ expect(req.mandatory).to be(false)
67
+ expect(req.confirm_timeout).to be_nil
68
+ end
69
+
70
+ it 'permite asignar mandatory y confirm_timeout' do
71
+ req = described_class.new('foo')
72
+
73
+ req.mandatory = true
74
+ req.confirm_timeout = 0.5
75
+
76
+ expect(req.mandatory).to be(true)
77
+ expect(req.confirm_timeout).to eq(0.5)
78
+ end
50
79
  end
51
80
  end