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.
- checksums.yaml +4 -4
- data/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
- data/.claude/commands/gem-ai-setup.md +174 -0
- data/.claude/commands/pr.md +53 -0
- data/.claude/commands/release.md +52 -0
- data/.claude/commands/rubocop.md +22 -0
- data/.claude/commands/service-ai-setup.md +168 -0
- data/.claude/commands/test.md +28 -0
- data/.claude/commands/yard.md +46 -0
- data/CHANGELOG.md +50 -15
- data/CLAUDE.md +240 -0
- data/README.md +154 -221
- data/Rakefile +19 -3
- data/docs/_index.md +50 -0
- data/docs/ai/_index.md +56 -0
- data/docs/ai/antipatterns.md +166 -0
- data/docs/ai/api.md +251 -0
- data/docs/ai/architecture.md +92 -0
- data/docs/ai/errors.md +158 -0
- data/docs/ai/faq_external.md +133 -0
- data/docs/ai/faq_internal.md +86 -0
- data/docs/ai/glossary.md +45 -0
- 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 +45 -21
- 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 +39 -18
- data/lib/bug_bunny/exception.rb +5 -1
- data/lib/bug_bunny/middleware/raise_error.rb +3 -3
- data/lib/bug_bunny/observability.rb +28 -6
- data/lib/bug_bunny/producer.rb +11 -13
- data/lib/bug_bunny/railtie.rb +8 -7
- data/lib/bug_bunny/request.rb +3 -11
- data/lib/bug_bunny/resource.rb +81 -41
- data/lib/bug_bunny/routing/route.rb +6 -1
- data/lib/bug_bunny/routing/route_set.rb +60 -22
- data/lib/bug_bunny/session.rb +18 -11
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +4 -2
- data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
- data/lib/tasks/bug_bunny.rake +50 -0
- data/plan_test.txt +63 -0
- data/skills-lock.json +10 -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 +50 -3
- data/sig/bug_bunny.rbs +0 -4
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'support/bunny_mocks'
|
|
5
|
+
|
|
6
|
+
RSpec.describe BugBunny::Consumer do
|
|
7
|
+
include BunnyMocks
|
|
8
|
+
|
|
9
|
+
let(:channel) { BunnyMocks::FakeChannel.new(true) }
|
|
10
|
+
let(:connection) { BunnyMocks::FakeConnection.new(true, channel) }
|
|
11
|
+
let(:consumer) { described_class.new(connection) }
|
|
12
|
+
|
|
13
|
+
let(:fake_session) do
|
|
14
|
+
session = instance_double(BugBunny::Session)
|
|
15
|
+
allow(session).to receive(:exchange).and_return(double('exchange'))
|
|
16
|
+
allow(session).to receive(:queue).and_return(fake_queue)
|
|
17
|
+
allow(session).to receive(:close)
|
|
18
|
+
session
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
let(:fake_queue) do
|
|
22
|
+
q = double('queue')
|
|
23
|
+
allow(q).to receive(:bind)
|
|
24
|
+
allow(q).to receive(:subscribe)
|
|
25
|
+
q
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
before do
|
|
29
|
+
consumer.instance_variable_set(:@session, fake_session)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#shutdown' do
|
|
33
|
+
it 'detiene el health timer' do
|
|
34
|
+
timer = instance_double(Concurrent::TimerTask)
|
|
35
|
+
expect(timer).to receive(:shutdown)
|
|
36
|
+
|
|
37
|
+
consumer.instance_variable_set(:@health_timer, timer)
|
|
38
|
+
consumer.shutdown
|
|
39
|
+
|
|
40
|
+
expect(consumer.instance_variable_get(:@health_timer)).to be_nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'es idempotente si no hay timer activo' do
|
|
44
|
+
consumer.instance_variable_set(:@health_timer, nil)
|
|
45
|
+
expect { consumer.shutdown }.not_to raise_error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'cierra la sesión' do
|
|
49
|
+
expect(fake_session).to receive(:close)
|
|
50
|
+
consumer.shutdown
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '#subscribe' do
|
|
55
|
+
it 'llama a shutdown en el ensure al salir normalmente' do
|
|
56
|
+
shutdown_called = false
|
|
57
|
+
consumer.define_singleton_method(:shutdown) { shutdown_called = true }
|
|
58
|
+
|
|
59
|
+
consumer.subscribe(
|
|
60
|
+
queue_name: 'q',
|
|
61
|
+
exchange_name: 'x',
|
|
62
|
+
routing_key: '#',
|
|
63
|
+
block: false
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(shutdown_called).to be(true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'llama a shutdown aunque subscribe falle con max_reconnect_attempts=1' do
|
|
70
|
+
shutdown_called = false
|
|
71
|
+
consumer.define_singleton_method(:shutdown) { shutdown_called = true }
|
|
72
|
+
|
|
73
|
+
allow(fake_session).to receive(:exchange).and_raise(RuntimeError, 'boom')
|
|
74
|
+
BugBunny.configuration.max_reconnect_attempts = 1
|
|
75
|
+
|
|
76
|
+
expect do
|
|
77
|
+
consumer.subscribe(
|
|
78
|
+
queue_name: 'q',
|
|
79
|
+
exchange_name: 'x',
|
|
80
|
+
routing_key: '#',
|
|
81
|
+
block: false
|
|
82
|
+
)
|
|
83
|
+
end.to raise_error(RuntimeError)
|
|
84
|
+
|
|
85
|
+
expect(shutdown_called).to be(true)
|
|
86
|
+
ensure
|
|
87
|
+
BugBunny.configuration.max_reconnect_attempts = nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe BugBunny::Controller, 'after_action' do
|
|
6
|
+
# Construye un controlador mínimo funcional y lo ejecuta directamente.
|
|
7
|
+
def call_controller(klass, action: :index, body: {})
|
|
8
|
+
klass.call(headers: { action: action.to_s }, body: body)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
describe '.after_action' do
|
|
12
|
+
it 'registra el callback y retorna void' do
|
|
13
|
+
klass = Class.new(BugBunny::Controller)
|
|
14
|
+
klass.after_action :log_after
|
|
15
|
+
expect(klass.after_actions[:_all_actions]).to include(:log_after)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'soporta la opción only: para restringir la acción' do
|
|
19
|
+
klass = Class.new(BugBunny::Controller)
|
|
20
|
+
klass.after_action :log_after, only: [:show]
|
|
21
|
+
expect(klass.after_actions[:show]).to include(:log_after)
|
|
22
|
+
expect(klass.after_actions[:_all_actions]).to be_nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'no muta la clase padre al registrar en la subclase' do
|
|
26
|
+
parent = Class.new(BugBunny::Controller)
|
|
27
|
+
child = Class.new(parent)
|
|
28
|
+
child.after_action :child_callback
|
|
29
|
+
|
|
30
|
+
expect(parent.after_actions[:_all_actions]).to be_nil
|
|
31
|
+
expect(child.after_actions[:_all_actions]).to include(:child_callback)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe 'ejecución' do
|
|
36
|
+
it 'ejecuta el callback después de la acción' do
|
|
37
|
+
log = []
|
|
38
|
+
klass = Class.new(BugBunny::Controller) do
|
|
39
|
+
after_action :record_after
|
|
40
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
41
|
+
define_method(:record_after) { log << :after }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
call_controller(klass)
|
|
45
|
+
expect(log).to eq(%i[action after])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'ejecuta múltiples after_actions en orden FIFO' do
|
|
49
|
+
log = []
|
|
50
|
+
klass = Class.new(BugBunny::Controller) do
|
|
51
|
+
after_action :first_after
|
|
52
|
+
after_action :second_after
|
|
53
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
54
|
+
define_method(:first_after) { log << :first }
|
|
55
|
+
define_method(:second_after) { log << :second }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
call_controller(klass)
|
|
59
|
+
expect(log).to eq(%i[action first second])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'ejecuta after_action después de before_action y de la acción' do
|
|
63
|
+
log = []
|
|
64
|
+
klass = Class.new(BugBunny::Controller) do
|
|
65
|
+
before_action :record_before
|
|
66
|
+
after_action :record_after
|
|
67
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
68
|
+
define_method(:record_before) { log << :before }
|
|
69
|
+
define_method(:record_after) { log << :after }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
call_controller(klass)
|
|
73
|
+
expect(log).to eq(%i[before action after])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'ejecuta after_action dentro del yield de around_action' do
|
|
77
|
+
log = []
|
|
78
|
+
klass = Class.new(BugBunny::Controller) do
|
|
79
|
+
around_action :wrap
|
|
80
|
+
after_action :record_after
|
|
81
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
82
|
+
define_method(:record_after) { log << :after }
|
|
83
|
+
define_method(:wrap) { |&blk| log << :around_pre; blk.call; log << :around_post }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
call_controller(klass)
|
|
87
|
+
# after_action corre dentro del yield del around, antes de :around_post
|
|
88
|
+
expect(log).to eq(%i[around_pre action after around_post])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'NO ejecuta after_action si before_action interrumpió con render' do
|
|
92
|
+
log = []
|
|
93
|
+
klass = Class.new(BugBunny::Controller) do
|
|
94
|
+
before_action :halt_early
|
|
95
|
+
after_action :record_after
|
|
96
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
97
|
+
define_method(:halt_early) { render status: 403, json: { error: 'forbidden' } }
|
|
98
|
+
define_method(:record_after) { log << :after }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
call_controller(klass)
|
|
102
|
+
expect(log).to be_empty
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'NO ejecuta after_action si la acción lanzó una excepción' do
|
|
106
|
+
log = []
|
|
107
|
+
klass = Class.new(BugBunny::Controller) do
|
|
108
|
+
after_action :record_after
|
|
109
|
+
define_method(:index) { raise 'boom' }
|
|
110
|
+
define_method(:record_after) { log << :after }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
call_controller(klass) # rescue_from genérico devuelve 500
|
|
114
|
+
expect(log).to be_empty
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'respeta only: y no ejecuta el callback en otras acciones' do
|
|
118
|
+
log = []
|
|
119
|
+
klass = Class.new(BugBunny::Controller) do
|
|
120
|
+
after_action :record_after, only: [:show]
|
|
121
|
+
define_method(:index) { log << :index_ran; render status: 200, json: {} }
|
|
122
|
+
define_method(:show) { log << :show_ran; render status: 200, json: {} }
|
|
123
|
+
define_method(:record_after) { log << :after }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
call_controller(klass, action: :index)
|
|
127
|
+
expect(log).to eq(%i[index_ran])
|
|
128
|
+
|
|
129
|
+
log.clear
|
|
130
|
+
call_controller(klass, action: :show)
|
|
131
|
+
expect(log).to eq(%i[show_ran after])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'hereda after_actions del padre y agrega los propios sin mutarlo' do
|
|
135
|
+
log = []
|
|
136
|
+
parent = Class.new(BugBunny::Controller) do
|
|
137
|
+
after_action :parent_after
|
|
138
|
+
define_method(:index) { log << :action; render status: 200, json: {} }
|
|
139
|
+
define_method(:parent_after) { log << :parent }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
child = Class.new(parent) do
|
|
143
|
+
after_action :child_after
|
|
144
|
+
define_method(:child_after) { log << :child }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
call_controller(child)
|
|
148
|
+
expect(log).to eq(%i[action parent child])
|
|
149
|
+
|
|
150
|
+
log.clear
|
|
151
|
+
call_controller(parent)
|
|
152
|
+
expect(log).to eq(%i[action parent])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'logger'
|
|
5
|
+
|
|
6
|
+
RSpec.describe BugBunny::Observability do
|
|
7
|
+
# Host class mínimo para ejercitar el mixin.
|
|
8
|
+
let(:host_class) do
|
|
9
|
+
Class.new do
|
|
10
|
+
include BugBunny::Observability
|
|
11
|
+
|
|
12
|
+
attr_writer :logger
|
|
13
|
+
|
|
14
|
+
def initialize(logger)
|
|
15
|
+
@logger = logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Expone safe_log públicamente solo para tests.
|
|
19
|
+
public :safe_log
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
let(:log_output) { StringIO.new }
|
|
24
|
+
let(:logger) { Logger.new(log_output) }
|
|
25
|
+
let(:host) { host_class.new(logger) }
|
|
26
|
+
|
|
27
|
+
# Extrae el mensaje del log (después del prefijo "D, [timestamp] DEBUG -- :")
|
|
28
|
+
def last_log_line
|
|
29
|
+
log_output.string.split("\n").last.to_s.sub(/\A.*?:\s*/, '')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '.sensitive_key? (módulo público)' do
|
|
33
|
+
subject(:sensitive?) { BugBunny::Observability.method(:sensitive_key?) }
|
|
34
|
+
|
|
35
|
+
context 'keys símbolo' do
|
|
36
|
+
it 'filtra :password' do
|
|
37
|
+
expect(BugBunny::Observability.sensitive_key?(:password)).to be(true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'filtra :token' do
|
|
41
|
+
expect(BugBunny::Observability.sensitive_key?(:token)).to be(true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'filtra :secret' do
|
|
45
|
+
expect(BugBunny::Observability.sensitive_key?(:secret)).to be(true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'filtra :api_key' do
|
|
49
|
+
expect(BugBunny::Observability.sensitive_key?(:api_key)).to be(true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'filtra :auth' do
|
|
53
|
+
expect(BugBunny::Observability.sensitive_key?(:auth)).to be(true)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
context 'keys string' do
|
|
58
|
+
it 'filtra "password"' do
|
|
59
|
+
expect(BugBunny::Observability.sensitive_key?('password')).to be(true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'filtra "Authorization" (case-insensitive)' do
|
|
63
|
+
expect(BugBunny::Observability.sensitive_key?('Authorization')).to be(true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'filtra "X-Api-Key" (case-insensitive)' do
|
|
67
|
+
expect(BugBunny::Observability.sensitive_key?('X-Api-Key')).to be(true)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
context 'partial matches' do
|
|
72
|
+
it 'filtra "user_password"' do
|
|
73
|
+
expect(BugBunny::Observability.sensitive_key?('user_password')).to be(true)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'filtra "access_token"' do
|
|
77
|
+
expect(BugBunny::Observability.sensitive_key?('access_token')).to be(true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'filtra "refresh_token"' do
|
|
81
|
+
expect(BugBunny::Observability.sensitive_key?('refresh_token')).to be(true)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'filtra "accessToken" (camelCase)' do
|
|
85
|
+
expect(BugBunny::Observability.sensitive_key?('accessToken')).to be(true)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'filtra "password2"' do
|
|
89
|
+
expect(BugBunny::Observability.sensitive_key?('password2')).to be(true)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'filtra "csrf_token"' do
|
|
93
|
+
expect(BugBunny::Observability.sensitive_key?('csrf_token')).to be(true)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'filtra "csrftoken"' do
|
|
97
|
+
expect(BugBunny::Observability.sensitive_key?('csrftoken')).to be(true)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'filtra "db_credentials"' do
|
|
101
|
+
expect(BugBunny::Observability.sensitive_key?('db_credentials')).to be(true)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'filtra "private_key"' do
|
|
105
|
+
expect(BugBunny::Observability.sensitive_key?('private_key')).to be(true)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'filtra "session_id"' do
|
|
109
|
+
expect(BugBunny::Observability.sensitive_key?('session_id')).to be(true)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
context 'sin falsos positivos' do
|
|
114
|
+
it 'no filtra "username"' do
|
|
115
|
+
expect(BugBunny::Observability.sensitive_key?('username')).to be(false)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'no filtra "user_email"' do
|
|
119
|
+
expect(BugBunny::Observability.sensitive_key?('user_email')).to be(false)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'no filtra "passport_number"' do
|
|
123
|
+
expect(BugBunny::Observability.sensitive_key?('passport_number')).to be(false)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'no filtra "status"' do
|
|
127
|
+
expect(BugBunny::Observability.sensitive_key?('status')).to be(false)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'no filtra "duration_s"' do
|
|
131
|
+
expect(BugBunny::Observability.sensitive_key?('duration_s')).to be(false)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
describe '#safe_log — filtrado en el output' do
|
|
137
|
+
it 'reemplaza el valor de una key sensible con [FILTERED]' do
|
|
138
|
+
host.safe_log(:info, 'test.event', password: 'secret123')
|
|
139
|
+
expect(last_log_line).to include('password=[FILTERED]')
|
|
140
|
+
expect(last_log_line).not_to include('secret123')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'filtra keys string sensibles' do
|
|
144
|
+
host.safe_log(:info, 'test.event', 'Authorization' => 'Bearer xyz')
|
|
145
|
+
expect(last_log_line).to include('Authorization=[FILTERED]')
|
|
146
|
+
expect(last_log_line).not_to include('Bearer xyz')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'filtra partial match en key' do
|
|
150
|
+
host.safe_log(:info, 'test.event', user_password: 'hunter2')
|
|
151
|
+
expect(last_log_line).to include('user_password=[FILTERED]')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'no filtra keys no sensibles' do
|
|
155
|
+
host.safe_log(:info, 'test.event', username: 'gabriel')
|
|
156
|
+
expect(last_log_line).to include('username=gabriel')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'filtra múltiples keys sensibles en el mismo log' do
|
|
160
|
+
host.safe_log(:info, 'test.event', token: 'abc', status: 'ok', secret: 'xyz')
|
|
161
|
+
line = last_log_line
|
|
162
|
+
expect(line).to include('token=[FILTERED]')
|
|
163
|
+
expect(line).to include('secret=[FILTERED]')
|
|
164
|
+
expect(line).to include('status=ok')
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
module ResourceAttributesSpec
|
|
6
|
+
class Product < BugBunny::Resource
|
|
7
|
+
attribute :name, :string
|
|
8
|
+
attribute :price, :decimal
|
|
9
|
+
attribute :active, :boolean
|
|
10
|
+
attribute :created_at, :datetime
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
RSpec.describe BugBunny::Resource do
|
|
15
|
+
let(:product_class) { ResourceAttributesSpec::Product }
|
|
16
|
+
|
|
17
|
+
describe 'ActiveModel::Attributes integration' do
|
|
18
|
+
it 'realiza la coerción de tipos para atributos definidos' do
|
|
19
|
+
product = product_class.new(
|
|
20
|
+
name: 'Teclado',
|
|
21
|
+
price: '25.50',
|
|
22
|
+
active: '1',
|
|
23
|
+
created_at: '2026-04-01 12:00:00'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
expect(product.name).to eq('Teclado')
|
|
27
|
+
expect(product.price).to be_a(BigDecimal)
|
|
28
|
+
expect(product.price).to eq(BigDecimal('25.50'))
|
|
29
|
+
expect(product.active).to be(true)
|
|
30
|
+
expect(product.created_at).to be_a(Time)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'permite atributos dinámicos que no están definidos' do
|
|
34
|
+
product = product_class.new(name: 'Teclado', category: 'Hardware')
|
|
35
|
+
|
|
36
|
+
expect(product.name).to eq('Teclado')
|
|
37
|
+
expect(product.category).to eq('Hardware')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'detecta cambios tanto en atributos definidos como dinámicos usando ActiveModel::Dirty' do
|
|
41
|
+
product = product_class.new(name: 'Teclado', price: 10.0)
|
|
42
|
+
product.persisted = true
|
|
43
|
+
product.clear_changes_information
|
|
44
|
+
|
|
45
|
+
# Cambio en atributo definido (tipado)
|
|
46
|
+
product.price = 15.0
|
|
47
|
+
# Cambio en atributo dinámico
|
|
48
|
+
product.sku = 'TK-123'
|
|
49
|
+
|
|
50
|
+
expect(product.changed?).to be(true)
|
|
51
|
+
expect(product.changed).to include('price', 'sku')
|
|
52
|
+
|
|
53
|
+
expect(product.changes_to_send).to eq({
|
|
54
|
+
'price' => 15.0,
|
|
55
|
+
'sku' => 'TK-123'
|
|
56
|
+
})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'devuelve el ID correctamente sin importar dónde esté almacenado (ID aliases)' do
|
|
60
|
+
p1 = product_class.new(id: '123')
|
|
61
|
+
p2 = product_class.new(ID: '456')
|
|
62
|
+
p3 = product_class.new(_id: '789')
|
|
63
|
+
|
|
64
|
+
expect(p1.id).to eq('123')
|
|
65
|
+
expect(p2.id).to eq('456')
|
|
66
|
+
expect(p3.id).to eq('789')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'support/bunny_mocks'
|
|
5
|
+
|
|
6
|
+
RSpec.describe BugBunny::Session do
|
|
7
|
+
include BunnyMocks
|
|
8
|
+
|
|
9
|
+
let(:channel) { BunnyMocks::FakeChannel.new(false).tap { |c| c.open = true } }
|
|
10
|
+
let(:connection) { BunnyMocks::FakeConnection.new(true, channel) }
|
|
11
|
+
let(:session) { described_class.new(connection) }
|
|
12
|
+
|
|
13
|
+
describe '#channel' do
|
|
14
|
+
context 'cuando el canal está abierto' do
|
|
15
|
+
it 'retorna el canal existente sin crear uno nuevo (fast path)' do
|
|
16
|
+
first = session.channel
|
|
17
|
+
second = session.channel
|
|
18
|
+
expect(second).to be(first)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
context 'cuando el canal está cerrado' do
|
|
23
|
+
it 'crea un canal nuevo' do
|
|
24
|
+
session.channel # inicializa
|
|
25
|
+
|
|
26
|
+
new_channel = BunnyMocks::FakeChannel.new(true)
|
|
27
|
+
session.instance_variable_get(:@channel).open = false
|
|
28
|
+
connection.channel_to_return = new_channel
|
|
29
|
+
|
|
30
|
+
expect(session.channel).to be(new_channel)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
context 'con múltiples threads simultáneos' do
|
|
35
|
+
it 'llama a create_channel! exactamente una vez aunque varios threads compitan' do
|
|
36
|
+
fresh_session = described_class.new(connection)
|
|
37
|
+
create_count = Concurrent::AtomicFixnum.new(0)
|
|
38
|
+
|
|
39
|
+
fresh_session.define_singleton_method(:create_channel!) do
|
|
40
|
+
create_count.increment
|
|
41
|
+
super()
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
threads = 10.times.map { Thread.new { fresh_session.channel } }
|
|
45
|
+
threads.each(&:join)
|
|
46
|
+
|
|
47
|
+
expect(create_count.value).to eq(1)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
context 'cuando la conexión está cerrada' do
|
|
52
|
+
it 'reconecta transparentemente' do
|
|
53
|
+
closed_conn = BunnyMocks::FakeConnection.new(false, channel)
|
|
54
|
+
s = described_class.new(closed_conn)
|
|
55
|
+
|
|
56
|
+
s.channel
|
|
57
|
+
|
|
58
|
+
expect(closed_conn.open?).to be(true)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'lanza CommunicationError si la reconexión falla' do
|
|
62
|
+
bad_conn = BunnyMocks::FakeConnection.new(false, channel)
|
|
63
|
+
bad_conn.define_singleton_method(:start) { raise RuntimeError, 'refused' }
|
|
64
|
+
|
|
65
|
+
s = described_class.new(bad_conn)
|
|
66
|
+
|
|
67
|
+
expect { s.channel }.to raise_error(BugBunny::CommunicationError)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe '#close' do
|
|
73
|
+
it 'cierra el canal y lo nilifica' do
|
|
74
|
+
session.channel
|
|
75
|
+
session.close
|
|
76
|
+
|
|
77
|
+
expect(session.instance_variable_get(:@channel)).to be_nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'es idempotente — no explota si se llama dos veces' do
|
|
81
|
+
session.channel
|
|
82
|
+
session.close
|
|
83
|
+
expect { session.close }.not_to raise_error
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'es thread-safe junto con #channel' do
|
|
87
|
+
errors = []
|
|
88
|
+
|
|
89
|
+
threads = [
|
|
90
|
+
Thread.new { 10.times { session.channel rescue nil } },
|
|
91
|
+
Thread.new { 10.times { session.close rescue nil } }
|
|
92
|
+
]
|
|
93
|
+
threads.each(&:join)
|
|
94
|
+
|
|
95
|
+
expect(errors).to be_empty
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bug_bunny
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.
|
|
4
|
+
version: 4.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- gabix
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-04-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bunny
|
|
@@ -229,9 +229,36 @@ executables: []
|
|
|
229
229
|
extensions: []
|
|
230
230
|
extra_rdoc_files: []
|
|
231
231
|
files:
|
|
232
|
+
- ".agents/skills/rabbitmq-expert/SKILL.md"
|
|
233
|
+
- ".claude/commands/gem-ai-setup.md"
|
|
234
|
+
- ".claude/commands/pr.md"
|
|
235
|
+
- ".claude/commands/release.md"
|
|
236
|
+
- ".claude/commands/rubocop.md"
|
|
237
|
+
- ".claude/commands/service-ai-setup.md"
|
|
238
|
+
- ".claude/commands/test.md"
|
|
239
|
+
- ".claude/commands/yard.md"
|
|
232
240
|
- CHANGELOG.md
|
|
241
|
+
- CLAUDE.md
|
|
233
242
|
- README.md
|
|
234
243
|
- Rakefile
|
|
244
|
+
- docs/_index.md
|
|
245
|
+
- docs/ai/_index.md
|
|
246
|
+
- docs/ai/antipatterns.md
|
|
247
|
+
- docs/ai/api.md
|
|
248
|
+
- docs/ai/architecture.md
|
|
249
|
+
- docs/ai/errors.md
|
|
250
|
+
- docs/ai/faq_external.md
|
|
251
|
+
- docs/ai/faq_internal.md
|
|
252
|
+
- docs/ai/glossary.md
|
|
253
|
+
- docs/concepts.md
|
|
254
|
+
- docs/howto/controller.md
|
|
255
|
+
- docs/howto/middleware_client.md
|
|
256
|
+
- docs/howto/middleware_consumer.md
|
|
257
|
+
- docs/howto/rails.md
|
|
258
|
+
- docs/howto/resource.md
|
|
259
|
+
- docs/howto/routing.md
|
|
260
|
+
- docs/howto/testing.md
|
|
261
|
+
- docs/howto/tracing.md
|
|
235
262
|
- initializer_example.rb
|
|
236
263
|
- lib/bug_bunny.rb
|
|
237
264
|
- lib/bug_bunny/client.rb
|
|
@@ -255,7 +282,26 @@ files:
|
|
|
255
282
|
- lib/bug_bunny/version.rb
|
|
256
283
|
- lib/generators/bug_bunny/install/install_generator.rb
|
|
257
284
|
- lib/generators/bug_bunny/install/templates/initializer.rb
|
|
258
|
-
-
|
|
285
|
+
- lib/tasks/bug_bunny.rake
|
|
286
|
+
- plan_test.txt
|
|
287
|
+
- skills-lock.json
|
|
288
|
+
- spec/integration/client_spec.rb
|
|
289
|
+
- spec/integration/consumer_middleware_spec.rb
|
|
290
|
+
- spec/integration/controller_spec.rb
|
|
291
|
+
- spec/integration/error_handling_spec.rb
|
|
292
|
+
- spec/integration/infrastructure_spec.rb
|
|
293
|
+
- spec/integration/resource_spec.rb
|
|
294
|
+
- spec/spec_helper.rb
|
|
295
|
+
- spec/support/bunny_mocks.rb
|
|
296
|
+
- spec/support/integration_helper.rb
|
|
297
|
+
- spec/unit/client_session_pool_spec.rb
|
|
298
|
+
- spec/unit/configuration_spec.rb
|
|
299
|
+
- spec/unit/consumer_middleware_spec.rb
|
|
300
|
+
- spec/unit/consumer_spec.rb
|
|
301
|
+
- spec/unit/controller_after_action_spec.rb
|
|
302
|
+
- spec/unit/observability_spec.rb
|
|
303
|
+
- spec/unit/resource_attributes_spec.rb
|
|
304
|
+
- spec/unit/session_spec.rb
|
|
259
305
|
- test/integration/infrastructure_test.rb
|
|
260
306
|
- test/integration/manual_client_test.rb
|
|
261
307
|
- test/test_helper.rb
|
|
@@ -266,6 +312,7 @@ metadata:
|
|
|
266
312
|
homepage_uri: https://github.com/gedera/bug_bunny
|
|
267
313
|
source_code_uri: https://github.com/gedera/bug_bunny
|
|
268
314
|
changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
|
|
315
|
+
documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.8.0/docs
|
|
269
316
|
post_install_message:
|
|
270
317
|
rdoc_options: []
|
|
271
318
|
require_paths:
|
data/sig/bug_bunny.rbs
DELETED