bug_bunny 4.6.1 → 4.8.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
  3. data/.claude/commands/gem-ai-setup.md +174 -0
  4. data/.claude/commands/pr.md +53 -0
  5. data/.claude/commands/release.md +52 -0
  6. data/.claude/commands/rubocop.md +22 -0
  7. data/.claude/commands/service-ai-setup.md +168 -0
  8. data/.claude/commands/test.md +28 -0
  9. data/.claude/commands/yard.md +46 -0
  10. data/CHANGELOG.md +50 -15
  11. data/CLAUDE.md +240 -0
  12. data/README.md +154 -221
  13. data/Rakefile +19 -3
  14. data/docs/_index.md +50 -0
  15. data/docs/ai/_index.md +56 -0
  16. data/docs/ai/antipatterns.md +166 -0
  17. data/docs/ai/api.md +251 -0
  18. data/docs/ai/architecture.md +92 -0
  19. data/docs/ai/errors.md +158 -0
  20. data/docs/ai/faq_external.md +133 -0
  21. data/docs/ai/faq_internal.md +86 -0
  22. data/docs/ai/glossary.md +45 -0
  23. data/docs/concepts.md +140 -0
  24. data/docs/howto/controller.md +194 -0
  25. data/docs/howto/middleware_client.md +119 -0
  26. data/docs/howto/middleware_consumer.md +127 -0
  27. data/docs/howto/rails.md +214 -0
  28. data/docs/howto/resource.md +200 -0
  29. data/docs/howto/routing.md +133 -0
  30. data/docs/howto/testing.md +259 -0
  31. data/docs/howto/tracing.md +119 -0
  32. data/lib/bug_bunny/client.rb +45 -21
  33. data/lib/bug_bunny/configuration.rb +63 -0
  34. data/lib/bug_bunny/consumer.rb +51 -37
  35. data/lib/bug_bunny/consumer_middleware.rb +14 -5
  36. data/lib/bug_bunny/controller.rb +39 -18
  37. data/lib/bug_bunny/exception.rb +5 -1
  38. data/lib/bug_bunny/middleware/raise_error.rb +3 -3
  39. data/lib/bug_bunny/observability.rb +28 -6
  40. data/lib/bug_bunny/producer.rb +11 -13
  41. data/lib/bug_bunny/railtie.rb +8 -7
  42. data/lib/bug_bunny/request.rb +3 -11
  43. data/lib/bug_bunny/resource.rb +81 -41
  44. data/lib/bug_bunny/routing/route.rb +6 -1
  45. data/lib/bug_bunny/routing/route_set.rb +60 -22
  46. data/lib/bug_bunny/session.rb +18 -11
  47. data/lib/bug_bunny/version.rb +1 -1
  48. data/lib/bug_bunny.rb +4 -2
  49. data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
  50. data/lib/tasks/bug_bunny.rake +50 -0
  51. data/plan_test.txt +63 -0
  52. data/skills-lock.json +10 -0
  53. data/spec/integration/client_spec.rb +117 -0
  54. data/spec/integration/consumer_middleware_spec.rb +86 -0
  55. data/spec/integration/controller_spec.rb +140 -0
  56. data/spec/integration/error_handling_spec.rb +57 -0
  57. data/spec/integration/infrastructure_spec.rb +52 -0
  58. data/spec/integration/resource_spec.rb +113 -0
  59. data/spec/spec_helper.rb +70 -0
  60. data/spec/support/bunny_mocks.rb +18 -0
  61. data/spec/support/integration_helper.rb +87 -0
  62. data/spec/unit/client_session_pool_spec.rb +159 -0
  63. data/spec/unit/configuration_spec.rb +164 -0
  64. data/spec/unit/consumer_middleware_spec.rb +129 -0
  65. data/spec/unit/consumer_spec.rb +90 -0
  66. data/spec/unit/controller_after_action_spec.rb +155 -0
  67. data/spec/unit/observability_spec.rb +167 -0
  68. data/spec/unit/resource_attributes_spec.rb +69 -0
  69. data/spec/unit/session_spec.rb +98 -0
  70. metadata +50 -3
  71. data/sig/bug_bunny.rbs +0 -4
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ namespace :bug_bunny do
6
+ desc 'Sync BugBunny AI docs reference in CLAUDE.md with the installed version'
7
+ task :sync do
8
+ spec = Gem::Specification.find_by_name('bug_bunny')
9
+ version = spec.version.to_s
10
+ docs_path = File.join(spec.gem_dir, 'docs', 'ai')
11
+ claude_md_path = File.join(Dir.pwd, 'CLAUDE.md')
12
+
13
+ content = if File.exist?(claude_md_path)
14
+ File.read(claude_md_path)
15
+ else
16
+ app_name = File.basename(Dir.pwd).split(/[-_]/).map(&:capitalize).join
17
+ puts 'bug_bunny:sync — CLAUDE.md not found, creating it.'
18
+ "# #{app_name}\n"
19
+ end
20
+
21
+ # Idempotent: same version already present, nothing to do
22
+ if content.include?('### bug_bunny') && content.include?("**Version:** #{version}")
23
+ puts "bug_bunny:sync — already at #{version}, nothing to do."
24
+ next
25
+ end
26
+
27
+ block = <<~BLOCK
28
+ ### bug_bunny
29
+ - **Version:** #{version}
30
+ - **Docs:** #{docs_path}
31
+ - **Updated:** #{Date.today}
32
+ BLOCK
33
+
34
+ # Replace existing block if present
35
+ if content.match?(/^### bug_bunny\n/)
36
+ updated = content.gsub(/^### bug_bunny\n(?:- \*\*.*\n)*/, block)
37
+ File.write(claude_md_path, updated)
38
+ puts "bug_bunny:sync — updated to #{version} in CLAUDE.md"
39
+ elsif content.include?('## Gemas internas')
40
+ # Append under existing section
41
+ updated = content.sub(/^## Gemas internas\n/, "## Gemas internas\n\n#{block}")
42
+ File.write(claude_md_path, updated)
43
+ puts "bug_bunny:sync — added #{version} under '## Gemas internas' in CLAUDE.md"
44
+ else
45
+ # Create section at end of file
46
+ File.write(claude_md_path, content.rstrip + "\n\n## Gemas internas\n\n#{block}")
47
+ puts "bug_bunny:sync — added '## Gemas internas' section with #{version} to CLAUDE.md"
48
+ end
49
+ end
50
+ end
data/plan_test.txt ADDED
@@ -0,0 +1,63 @@
1
+ 📋 Plan de Testing: BugBunny v3.1 (Extendido)
2
+ 🟢 Nivel 0: Infraestructura (Los Cimientos)
3
+ Test 0.0: test_helper capaz de levantar consumers con Exchanges Direct, Topic y Fanout.
4
+
5
+ Test 0.1: Control de "Namespace" dinámico en los tests para evitar colisión de nombres de clase.
6
+
7
+ 🔵 Nivel 1: El Recurso (Resource ORM)
8
+ Test 1.1: CRUD Completo (Create, Find, Update) con Exchange Topic (Default).
9
+
10
+ Test 1.2: Manejo de Errores (404, 500, Timeout).
11
+
12
+ Test 1.3: Resource.where con filtros anidados (Query String).
13
+
14
+ 🟠 Nivel 2: Cliente Manual (Publisher Mode)
15
+ Este nivel prueba el uso de la gema como un cliente RabbitMQ puro, sin la magia de Active Record.
16
+
17
+ Test 2.1 (Fire-and-Forget):
18
+
19
+ Usar client.publish('logs.error', body: { msg: 'Crash' }).
20
+
21
+ Verificar que el mensaje llega a la cola sin esperar respuesta.
22
+
23
+ Test 2.2 (RPC Manual):
24
+
25
+ Usar client.request('users/123', method: :get).
26
+
27
+ Verificar que devuelve un hash parseado y maneja el timeout.
28
+
29
+ Test 2.3 (Raw Bytes):
30
+
31
+ Enviar un string crudo (no JSON) y verificar que llega intacto.
32
+
33
+ 🟣 Nivel 3: Topologías de Exchange (Routing Complejo)
34
+ Aquí probamos que la gema respeta las reglas de RabbitMQ.
35
+
36
+ Test 3.1 (Topic Wildcards):
37
+
38
+ Worker escucha en notifications.*.
39
+
40
+ Cliente envía a notifications.email y notifications.sms.
41
+
42
+ Verificar que ambos llegan.
43
+
44
+ Test 3.2 (Direct Strict):
45
+
46
+ Worker escucha en orders_queue (Direct).
47
+
48
+ Cliente envía con routing key incorrecta.
49
+
50
+ Verificar que NO llega (o se va a Dead Letter si hubiera).
51
+
52
+ Test 3.3 (Fanout Broadcast):
53
+
54
+ Levantar 2 Workers escuchando el mismo Exchange Fanout.
55
+
56
+ Cliente publica 1 mensaje.
57
+
58
+ Verificar que AMBOS workers reciben el mensaje.
59
+
60
+ 🟡 Nivel 4: Observabilidad y Middlewares
61
+ Test 4.1 (Tracing): Inyección y propagación de correlation_id (Cliente Manual -> Worker).
62
+
63
+ Test 4.2 (Middleware Chain): Verificar que un middleware puede modificar el body antes de salir.
data/skills-lock.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "skills": {
4
+ "rabbitmq-expert": {
5
+ "source": "martinholovsky/claude-skills-generator",
6
+ "sourceType": "github",
7
+ "computedHash": "584e368d2811078acfb437c22ca07f1c93da52cbdcabcc8c24562128185c4b6b"
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/integration_helper'
5
+
6
+ module ClientSpec
7
+ class EchoController < BugBunny::Controller
8
+ def index
9
+ render status: 200, json: { received: params[:message], via: 'ClientSpec::EchoController' }
10
+ end
11
+ end
12
+ end
13
+
14
+ RSpec.describe BugBunny::Client, :integration do
15
+ let(:queue) { unique('client_q') }
16
+ let(:exchange) { unique('client_x') }
17
+ let(:client) { described_class.new(pool: TEST_POOL) }
18
+
19
+ before { BugBunny.configure { |c| c.controller_namespace = 'ClientSpec' } }
20
+ after { BugBunny.configure { |c| c.controller_namespace = 'BugBunny::Controllers' } }
21
+
22
+ describe '#publish' do
23
+ context 'con exchange topic' do
24
+ it 'entrega el mensaje con el routing key correcto' do
25
+ with_spy_worker(queue: queue, exchange: exchange, exchange_type: 'topic', routing_key: 'logs.#') do |messages|
26
+ client.publish('logs.error', exchange: exchange, exchange_type: 'topic', body: { level: 'error' })
27
+
28
+ msg = wait_for_message(messages)
29
+ expect(msg[:routing_key]).to eq('logs.error')
30
+ end
31
+ end
32
+ end
33
+
34
+ context 'con exchange direct' do
35
+ it 'entrega el mensaje al routing key exacto' do
36
+ with_spy_worker(queue: queue, exchange: exchange, exchange_type: 'direct', routing_key: 'alerts') do |messages|
37
+ client.publish('alerts', exchange: exchange, exchange_type: 'direct', body: { alert: true })
38
+
39
+ msg = wait_for_message(messages)
40
+ expect(msg[:routing_key]).to eq('alerts')
41
+ end
42
+ end
43
+ end
44
+
45
+ context 'con exchange fanout' do
46
+ it 'entrega el mensaje ignorando el routing key' do
47
+ with_spy_worker(queue: queue, exchange: exchange, exchange_type: 'fanout', routing_key: '') do |messages|
48
+ client.publish('cualquier.key', exchange: exchange, exchange_type: 'fanout', body: { data: 1 })
49
+
50
+ msg = wait_for_message(messages)
51
+ expect(msg[:routing_key]).to eq('cualquier.key')
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ describe '#request (RPC)' do
58
+ context 'con exchange topic' do
59
+ it 'retorna la respuesta del controlador' do
60
+ with_running_worker(queue: queue, exchange: exchange, exchange_type: 'topic', routing_key: 'echo') do
61
+ response = client.request('echo',
62
+ method: :get, exchange: exchange, exchange_type: 'topic',
63
+ body: { message: 'hello_topic' })
64
+
65
+ expect(response['status']).to eq(200)
66
+ expect(response['body']['received']).to eq('hello_topic')
67
+ end
68
+ end
69
+ end
70
+
71
+ context 'con exchange direct' do
72
+ it 'retorna la respuesta del controlador' do
73
+ with_running_worker(queue: queue, exchange: exchange, exchange_type: 'direct', routing_key: 'echo') do
74
+ response = client.request('echo',
75
+ method: :get, routing_key: 'echo',
76
+ exchange: exchange, exchange_type: 'direct',
77
+ body: { message: 'hello_direct' })
78
+
79
+ expect(response['status']).to eq(200)
80
+ expect(response['body']['received']).to eq('hello_direct')
81
+ end
82
+ end
83
+ end
84
+
85
+ context 'con exchange fanout' do
86
+ it 'retorna la respuesta del controlador' do
87
+ with_running_worker(queue: queue, exchange: exchange, exchange_type: 'fanout', routing_key: '') do
88
+ response = client.request('echo',
89
+ method: :get, exchange: exchange, exchange_type: 'fanout',
90
+ body: { message: 'hello_fanout' })
91
+
92
+ expect(response['status']).to eq(200)
93
+ expect(response['body']['received']).to eq('hello_fanout')
94
+ end
95
+ end
96
+ end
97
+
98
+ context 'con exchange_options personalizadas (cascada nivel 3)' do
99
+ it 'publica sin error PRECONDITION_FAILED' do
100
+ custom_x = unique('custom_x')
101
+ conn = BugBunny.create_connection
102
+ ch = conn.create_channel
103
+ ch.direct(custom_x, durable: true, auto_delete: true)
104
+
105
+ expect do
106
+ client.publish('key',
107
+ exchange: custom_x, exchange_type: 'direct',
108
+ exchange_options: { durable: true, auto_delete: true },
109
+ body: { test: true })
110
+ end.not_to raise_error
111
+ ensure
112
+ ch&.exchange_delete(custom_x) rescue nil
113
+ conn&.close rescue nil
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/integration_helper'
5
+
6
+ module MiddlewareSpec
7
+ class PingController < BugBunny::Controller
8
+ def index
9
+ render status: 200, json: { pong: true }
10
+ end
11
+ end
12
+ end
13
+
14
+ # Middleware de prueba que registra las llamadas en un array compartido
15
+ class TrackingMiddleware < BugBunny::ConsumerMiddleware::Base
16
+ def self.calls
17
+ @calls ||= []
18
+ end
19
+
20
+ def self.reset!
21
+ @calls = []
22
+ end
23
+
24
+ def call(delivery_info, properties, body)
25
+ self.class.calls << {
26
+ routing_key: delivery_info.routing_key,
27
+ headers: properties.headers
28
+ }
29
+ @app.call(delivery_info, properties, body)
30
+ end
31
+ end
32
+
33
+ RSpec.describe 'Consumer Middleware Stack', :integration do
34
+ let(:queue) { unique('middleware_q') }
35
+ let(:exchange) { unique('middleware_x') }
36
+ let(:client) { BugBunny::Client.new(pool: TEST_POOL) }
37
+
38
+ before do
39
+ BugBunny.configure { |c| c.controller_namespace = 'MiddlewareSpec' }
40
+ TrackingMiddleware.reset!
41
+ BugBunny.consumer_middlewares.use TrackingMiddleware
42
+ end
43
+
44
+ after do
45
+ BugBunny.configure { |c| c.controller_namespace = 'BugBunny::Controllers' }
46
+ # Limpiamos el middleware para no afectar otros specs
47
+ BugBunny.configuration.instance_variable_set(:@consumer_middlewares,
48
+ BugBunny::ConsumerMiddleware::Stack.new)
49
+ end
50
+
51
+ it 'ejecuta el middleware antes de process_message' do
52
+ with_running_worker(queue: queue, exchange: exchange, routing_key: 'ping') do
53
+ client.request('ping', method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'ping')
54
+
55
+ expect(TrackingMiddleware.calls).not_to be_empty
56
+ expect(TrackingMiddleware.calls.first[:routing_key]).to eq('ping')
57
+ end
58
+ end
59
+
60
+ it 'ejecuta el middleware para cada mensaje recibido' do
61
+ with_running_worker(queue: queue, exchange: exchange, routing_key: 'ping') do
62
+ 3.times { client.request('ping', method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'ping') }
63
+
64
+ expect(TrackingMiddleware.calls.length).to eq(3)
65
+ end
66
+ end
67
+
68
+ describe 'rpc_reply_headers' do
69
+ it 'inyecta headers en el reply del consumer' do
70
+ received_headers = nil
71
+
72
+ BugBunny.configuration.rpc_reply_headers = -> { { 'X-Test-Header' => 'from-consumer' } }
73
+ BugBunny.configuration.on_rpc_reply = ->(headers) { received_headers = headers }
74
+
75
+ with_running_worker(queue: queue, exchange: exchange, routing_key: 'ping') do
76
+ client.request('ping', method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'ping')
77
+ end
78
+
79
+ expect(received_headers).not_to be_nil
80
+ expect(received_headers['X-Test-Header']).to eq('from-consumer')
81
+ ensure
82
+ BugBunny.configuration.rpc_reply_headers = nil
83
+ BugBunny.configuration.on_rpc_reply = nil
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/integration_helper'
5
+
6
+ module ControllerSpec
7
+ class PingController < BugBunny::Controller
8
+ before_action :set_user
9
+
10
+ def index
11
+ render status: 200, json: { pong: true, user: @user }
12
+ end
13
+
14
+ def show
15
+ render status: 200, json: { id: params[:id], user: @user }
16
+ end
17
+
18
+ def create
19
+ render status: 201, json: { created: params[:name] }
20
+ end
21
+
22
+ private
23
+
24
+ def set_user
25
+ @user = 'test_user'
26
+ end
27
+ end
28
+
29
+ class AroundController < BugBunny::Controller
30
+ around_action :wrap_with_timing
31
+
32
+ def index
33
+ render status: 200, json: { action: 'index' }
34
+ end
35
+
36
+ private
37
+
38
+ def wrap_with_timing
39
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
40
+ yield
41
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
42
+ # Solo verificamos que el around_action ejecutó el bloque
43
+ raise 'negative elapsed' if elapsed < 0
44
+ end
45
+ end
46
+
47
+ class RescueController < BugBunny::Controller
48
+ rescue_from StandardError, with: :handle_error
49
+
50
+ def index
51
+ raise ArgumentError, 'something went wrong'
52
+ end
53
+
54
+ private
55
+
56
+ def handle_error(e)
57
+ render status: 422, json: { error: e.message }
58
+ end
59
+ end
60
+
61
+ class NodeController < BugBunny::Controller
62
+ def index
63
+ nodes = params[:q] ? [{ status: params[:q][:status] }] : []
64
+ render status: 200, json: nodes
65
+ end
66
+
67
+ def show
68
+ render status: 200, json: { id: params[:id] }
69
+ end
70
+
71
+ def create
72
+ render status: 201, json: { node: params[:name] }
73
+ end
74
+
75
+ def update
76
+ render status: 200, json: { id: params[:id], updated: true }
77
+ end
78
+
79
+ def destroy
80
+ render status: 200, json: { id: params[:id], deleted: true }
81
+ end
82
+ end
83
+ end
84
+
85
+ RSpec.describe BugBunny::Controller, :integration do
86
+ let(:queue) { unique('controller_q') }
87
+ let(:exchange) { unique('controller_x') }
88
+ let(:client) { BugBunny::Client.new(pool: TEST_POOL) }
89
+
90
+ before { BugBunny.configure { |c| c.controller_namespace = 'ControllerSpec' } }
91
+ after { BugBunny.configure { |c| c.controller_namespace = 'BugBunny::Controllers' } }
92
+
93
+ describe 'before_action' do
94
+ it 'ejecuta el callback antes de la acción y expone la variable de instancia' do
95
+ with_running_worker(queue: queue, exchange: exchange) do
96
+ response = client.request('ping',
97
+ method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'ping')
98
+
99
+ expect(response['status']).to eq(200)
100
+ expect(response['body']['user']).to eq('test_user')
101
+ end
102
+ end
103
+ end
104
+
105
+ describe 'around_action' do
106
+ it 'envuelve la acción y la ejecuta correctamente' do
107
+ with_running_worker(queue: queue, exchange: exchange, routing_key: 'around') do
108
+ response = client.request('around',
109
+ method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'around')
110
+
111
+ expect(response['status']).to eq(200)
112
+ expect(response['body']['action']).to eq('index')
113
+ end
114
+ end
115
+ end
116
+
117
+ describe 'rescue_from' do
118
+ it 'captura la excepción y retorna la respuesta del handler' do
119
+ with_running_worker(queue: queue, exchange: exchange, routing_key: 'rescue') do
120
+ response = client.request('rescue',
121
+ method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'rescue')
122
+
123
+ expect(response['status']).to eq(422)
124
+ expect(response['body']['error']).to eq('something went wrong')
125
+ end
126
+ end
127
+ end
128
+
129
+ describe 'params desde query string' do
130
+ it 'parsea los params del path y los expone en el controlador' do
131
+ with_running_worker(queue: queue, exchange: exchange) do
132
+ response = client.request('ping/42',
133
+ method: :get, exchange: exchange, exchange_type: 'topic')
134
+
135
+ expect(response['status']).to eq(200)
136
+ expect(response['body']['id']).to eq('42')
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/integration_helper'
5
+
6
+ module ErrorSpec
7
+ class BoomController < BugBunny::Controller
8
+ def index
9
+ raise StandardError, 'boom interno'
10
+ end
11
+ end
12
+ end
13
+
14
+ RSpec.describe 'Error handling', :integration do
15
+ let(:queue) { unique('error_q') }
16
+ let(:exchange) { unique('error_x') }
17
+ let(:client) { BugBunny::Client.new(pool: TEST_POOL) }
18
+
19
+ before { BugBunny.configure { |c| c.controller_namespace = 'ErrorSpec' } }
20
+ after { BugBunny.configure { |c| c.controller_namespace = 'BugBunny::Controllers' } }
21
+
22
+ describe '404 — ruta no encontrada' do
23
+ it 'retorna status 404 cuando no hay ruta para el path' do
24
+ with_running_worker(queue: queue, exchange: exchange, routing_key: 'boom') do
25
+ response = client.request('ruta_inexistente',
26
+ method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'boom')
27
+
28
+ expect(response['status']).to eq(404)
29
+ end
30
+ end
31
+ end
32
+
33
+ describe '404 — controlador no encontrado' do
34
+ it 'retorna status 404 cuando el controlador no existe en el namespace' do
35
+ BugBunny.configure { |c| c.controller_namespace = 'NamespaceQueNoExiste' }
36
+
37
+ with_running_worker(queue: queue, exchange: exchange, routing_key: 'boom') do
38
+ response = client.request('boom',
39
+ method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'boom')
40
+
41
+ expect(response['status']).to eq(404)
42
+ end
43
+ end
44
+ end
45
+
46
+ describe '500 — excepción en el controlador' do
47
+ it 'retorna status 500 cuando el controlador lanza una excepción no manejada' do
48
+ with_running_worker(queue: queue, exchange: exchange, routing_key: 'boom') do
49
+ response = client.request('boom',
50
+ method: :get, exchange: exchange, exchange_type: 'topic', routing_key: 'boom')
51
+
52
+ expect(response['status']).to eq(500)
53
+ expect(response['body']['error']).to eq('Internal Server Error')
54
+ end
55
+ end
56
+ end
57
+ end
@@ -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