startback 1.2.4 → 2.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.
@@ -0,0 +1,296 @@
1
+ require 'spec_helper'
2
+ require 'startback/event/bus/bunny'
3
+
4
+ module Startback
5
+ class Event
6
+ describe Bus::Bunny::Async do
7
+
8
+ # Exchange and queue names are per-example, see BunnyBroker#unique:
9
+ # both are durable, so they outlive the example that created them.
10
+ let(:type) { BunnyBroker.unique("Spec::Event") }
11
+ let(:processor) { BunnyBroker.unique("spec-processor") }
12
+ let(:url) { BunnyBroker.url }
13
+
14
+ let(:bus) { Bus::Bunny::Async.new(url) }
15
+
16
+ # Busses an example connected, closed afterwards whatever happens, so
17
+ # that a failing example does not leak a connection into the next one.
18
+ let(:opened) { [] }
19
+
20
+ def connected_bus(options = {})
21
+ Bus::Bunny::Async.new({ url: url }.merge(options)).tap do |b|
22
+ opened << b
23
+ b.connect unless options[:autoconnect]
24
+ end
25
+ end
26
+
27
+ before do
28
+ BunnyBroker.skip_unless_available!(self)
29
+ end
30
+
31
+ after do
32
+ opened.each { |b| b.disconnect rescue nil }
33
+ BunnyBroker.delete_topology(exchanges: [type], queues: [processor, "main"])
34
+ end
35
+
36
+ describe "connecting" do
37
+
38
+ it 'is not connected before connect is called' do
39
+ expect(bus.connected?).to be_falsey
40
+ end
41
+
42
+ it 'connects and disconnects' do
43
+ b = connected_bus
44
+ expect(b.connected?).to eql(true)
45
+ b.disconnect
46
+ expect(b.connected?).to be_falsey
47
+ end
48
+
49
+ it 'connects at construction when autoconnect is set' do
50
+ b = connected_bus(autoconnect: true)
51
+ expect(b.connected?).to eql(true)
52
+ end
53
+
54
+ it 'takes a String as being the url' do
55
+ b = Bus::Bunny::Async.new(url)
56
+ opened << b
57
+ expect(b.options[:url]).to eql(url)
58
+ b.connect
59
+ expect(b.connected?).to eql(true)
60
+ end
61
+
62
+ it 'defaults the url to STARTBACK_BUS_BUNNY_ASYNC_URL' do
63
+ # DEFAULT_OPTIONS captures the variable at load time, so the class
64
+ # constant is the observable, not a re-read of ENV here.
65
+ expect(Bus::Bunny::Async::DEFAULT_OPTIONS[:url])
66
+ .to eql(ENV['STARTBACK_BUS_BUNNY_ASYNC_URL'])
67
+ end
68
+
69
+ it 'refuses to hand out a channel before connecting' do
70
+ expect {
71
+ bus.channel
72
+ }.to raise_error(Startback::Errors::Error, /connect your bus first/)
73
+ end
74
+
75
+ end
76
+
77
+ describe "emitting and listening" do
78
+
79
+ it 'round trips an event through the broker with default options' do
80
+ # Regression test for the defaults themselves: RabbitMQ 4 refuses
81
+ # transient non-exclusive queues, which is what queue_options used
82
+ # to ask for. This example fails with a Timeout::Error there.
83
+ b = connected_bus
84
+ seen = Queue.new
85
+ b.listen(type, processor) { |body| seen << body }
86
+ b.emit(Event.new(type, { id: 12 }))
87
+
88
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
89
+ expect(received).not_to be_nil
90
+ parsed = JSON.parse(received)
91
+ expect(parsed["type"]).to eql(type)
92
+ expect(parsed["data"]).to eql({ "id" => 12 })
93
+ end
94
+
95
+ it 'hands the listener the raw JSON String, not an Event' do
96
+ # Documents an asymmetry with Bus::Memory::Async, which hands over
97
+ # an Event instance. Listeners are not portable between the two.
98
+ b = connected_bus
99
+ seen = Queue.new
100
+ b.listen(type, processor) { |body| seen << body }
101
+ b.emit(Event.new(type, { id: 12 }))
102
+
103
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
104
+ expect(received).to be_a(String)
105
+ end
106
+
107
+ it 'declares the exchange and the queue durable' do
108
+ b = connected_bus
109
+ b.listen(type, processor) { |body| }
110
+
111
+ # Redeclaring passively tells us what the broker actually holds:
112
+ # a mismatch on durable would raise Bunny::PreconditionFailed.
113
+ expect {
114
+ ch = b.channel
115
+ ch.fanout(type, durable: true, passive: true)
116
+ ch.queue(processor, durable: true, passive: true)
117
+ }.not_to raise_error
118
+ end
119
+
120
+ it 'allows mixing Symbol vs. String for event type' do
121
+ b = connected_bus
122
+ seen = Queue.new
123
+ b.listen(type.to_sym, processor) { |body| seen << body }
124
+ b.emit(Event.new(type.to_sym, { id: 12 }))
125
+
126
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
127
+ expect(received).not_to be_nil
128
+ expect(JSON.parse(received)["type"]).to eql(type)
129
+ end
130
+
131
+ it 'fans an event out to every processor queue' do
132
+ other = BunnyBroker.unique("spec-other")
133
+ b = connected_bus
134
+ one, two = Queue.new, Queue.new
135
+ b.listen(type, processor) { |body| one << body }
136
+ b.listen(type, other) { |body| two << body }
137
+ b.emit(Event.new(type, { id: 12 }))
138
+
139
+ expect(BunnyBroker.wait_for { one.pop unless one.empty? }).not_to be_nil
140
+ expect(BunnyBroker.wait_for { two.pop unless two.empty? }).not_to be_nil
141
+
142
+ BunnyBroker.delete_topology(queues: [other])
143
+ end
144
+
145
+ it 'does not deliver an event to a queue bound to another type' do
146
+ other_type = BunnyBroker.unique("Spec::Other")
147
+ b = connected_bus
148
+ seen = Queue.new
149
+ b.listen(other_type, processor) { |body| seen << body }
150
+ b.emit(Event.new(type, { id: 12 }))
151
+
152
+ # Nothing should arrive. Give the broker a real chance to prove us
153
+ # wrong before concluding, hence a short but non-zero wait.
154
+ expect(BunnyBroker.wait_for(1) { seen.pop unless seen.empty? }).to be_nil
155
+
156
+ BunnyBroker.delete_topology(exchanges: [other_type])
157
+ end
158
+
159
+ it 'requires a listener' do
160
+ b = connected_bus
161
+ expect {
162
+ b.listen(type, processor)
163
+ }.to raise_error(ArgumentError, /listener must be provided/)
164
+ end
165
+
166
+ end
167
+
168
+ describe "adopting a pre-existing topology" do
169
+
170
+ # What an older Startback left on the broker: a transient exchange.
171
+ # This is the upgrade path, and it must not need a broker-side
172
+ # migration. Only the exchange, because a transient queue cannot be
173
+ # declared at all from RabbitMQ 4.3 on -- see the last example here.
174
+ def declare_legacy_exchange!
175
+ conn = ::Bunny.new(url, log_level: :fatal)
176
+ conn.start
177
+ conn.create_channel.fanout(type, {})
178
+ conn.close
179
+ end
180
+
181
+ it 'emits through a transient exchange instead of dropping the event' do
182
+ declare_legacy_exchange!
183
+ b = connected_bus
184
+
185
+ seen = Queue.new
186
+ b.listen(type, processor) { |body| seen << body }
187
+ b.emit(Event.new(type, { id: 12 }))
188
+
189
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
190
+ expect(received).not_to be_nil
191
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
192
+ end
193
+
194
+ it 'leaves the adopted exchange alone rather than redeclaring it' do
195
+ declare_legacy_exchange!
196
+ b = connected_bus
197
+ b.listen(type, processor) { |body| }
198
+
199
+ # Still transient: adopting means taking what is there. Declaring
200
+ # it transient again would raise had the bus turned it durable.
201
+ conn = ::Bunny.new(url, log_level: :fatal)
202
+ conn.start
203
+ expect {
204
+ conn.create_channel.fanout(type, durable: false)
205
+ }.not_to raise_error
206
+ conn.close
207
+ end
208
+
209
+ it 'keeps the channel usable after the conflict' do
210
+ # The conflict closes the channel. Before the bus learned to renew
211
+ # it, the closed one stayed cached per thread and every later emit
212
+ # failed with "cannot use a closed channel" -- silently, since
213
+ # `emit` runs inside stop_errors -- whatever the event type.
214
+ declare_legacy_exchange!
215
+ b = connected_bus
216
+ b.emit(Event.new(type, { id: 1 }))
217
+
218
+ expect(b.channel.open?).to eql(true)
219
+
220
+ other_type = BunnyBroker.unique("Spec::Unrelated")
221
+ other_proc = BunnyBroker.unique("spec-unrelated")
222
+ seen = Queue.new
223
+ b.listen(other_type, other_proc) { |body| seen << body }
224
+ b.emit(Event.new(other_type, { id: 2 }))
225
+
226
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
227
+ expect(received).not_to be_nil
228
+
229
+ BunnyBroker.delete_topology(exchanges: [other_type], queues: [other_proc])
230
+ end
231
+
232
+ it 'keeps delivering when exchange AND queue were both transient' do
233
+ # The true state an older Startback leaves behind, and the one that
234
+ # matters: adopting two objects means two channel renewals, so the
235
+ # consumer ends up on a different channel than the one the exchange
236
+ # was adopted on. Probing on the shared channel would then have
237
+ # `emit` close the consumer's channel -- events silently lost.
238
+ #
239
+ # Only reproducible where transient queues can still be declared,
240
+ # i.e. RabbitMQ <= 4.2, which is why CI runs a broker of that
241
+ # generation alongside the current one.
242
+ BunnyBroker.skip_unless_transient_queues!(self)
243
+
244
+ conn = ::Bunny.new(url, log_level: :fatal)
245
+ conn.start
246
+ ch = conn.create_channel
247
+ ch.queue(processor, {}).bind(ch.fanout(type, {}))
248
+ conn.close
249
+
250
+ b = connected_bus
251
+ seen = Queue.new
252
+ b.listen(type, processor) { |body| seen << body }
253
+ b.emit(Event.new(type, { id: 12 }))
254
+
255
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
256
+ expect(received).not_to be_nil
257
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
258
+ end
259
+
260
+ end
261
+
262
+ describe "the asynchronous contract" do
263
+
264
+ it 'hides emit errors from the emitter' do
265
+ # An async bus MUST NOT let errors reach the emitter. Emitting
266
+ # without connecting raises inside, and must stay inside.
267
+ expect {
268
+ bus.emit(Event.new(type, { id: 12 }))
269
+ }.not_to raise_error
270
+ end
271
+
272
+ it 'keeps consuming after a listener raised' do
273
+ b = connected_bus
274
+ seen = Queue.new
275
+ calls = Queue.new
276
+ b.listen(type, processor) do |body|
277
+ calls << body
278
+ raise "listener blew up" if JSON.parse(body)["data"]["id"] == 1
279
+
280
+ seen << body
281
+ end
282
+
283
+ b.emit(Event.new(type, { id: 1 }))
284
+ expect(BunnyBroker.wait_for { calls.pop unless calls.empty? }).not_to be_nil
285
+
286
+ b.emit(Event.new(type, { id: 2 }))
287
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
288
+ expect(received).not_to be_nil
289
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 2 })
290
+ end
291
+
292
+ end
293
+
294
+ end
295
+ end
296
+ end
@@ -76,6 +76,25 @@ module Startback
76
76
  end
77
77
  end
78
78
 
79
+ context 'when a lowercase cache-control header is already set by the app' do
80
+ # This is what Rack 3 applications actually emit, Sinatra 4 included.
81
+ # The raw response triple is inspected here on purpose: Rack::Test
82
+ # normalizes header names on read, and would hide a duplicate.
83
+ subject do
84
+ app = Rack::Builder.new do
85
+ use AutoCaching
86
+ run ->(env){ [200, {"cache-control" => "priority"}, ["Hello error"]] }
87
+ end.to_app
88
+ _, headers, _ = app.call(Rack::MockRequest.env_for("/"))
89
+ headers
90
+ end
91
+
92
+ it 'lets the application win, and does not duplicate the header' do
93
+ expect(subject.keys.grep(/cache-control/i)).to eql(["cache-control"])
94
+ expect(subject["cache-control"]).to eql("priority")
95
+ end
96
+ end
97
+
79
98
  end # CatchAll
80
99
  end # module Web
81
100
  end # module Startback
@@ -134,6 +134,27 @@ module Startback
134
134
  end
135
135
  end
136
136
 
137
+ context 'when the app sets specific headers in lowercase' do
138
+ # This is what Rack 3 applications actually emit, Sinatra 4 included.
139
+ # The raw response triple is inspected here on purpose: Rack::Test
140
+ # normalizes header names on read, and would hide a duplicate.
141
+ subject do
142
+ app = Rack::Builder.new do
143
+ use CorsHeaders
144
+ run ->(env){ [200, {'access-control-allow-methods' => "POST"}, ["Hello world"]] }
145
+ end.to_app
146
+ env = Rack::MockRequest.env_for("/", "HTTP_ORIGIN" => "https://test.com")
147
+ _, headers, _ = app.call(env)
148
+ headers
149
+ end
150
+
151
+ it 'does not override them, and does not duplicate them either' do
152
+ expect(subject.keys.grep(/access-control-allow-methods/i))
153
+ .to eql(["access-control-allow-methods"])
154
+ expect(subject["access-control-allow-methods"]).to eql("POST")
155
+ end
156
+ end
157
+
137
158
  end # CatchAll
138
159
  end # module Web
139
160
  end # module Startback