airbrake-ruby 1.0.0.rc.1

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,690 @@
1
+ # coding: utf-8
2
+ require 'spec_helper'
3
+
4
+ RSpec.describe Airbrake::Notifier do
5
+ def expect_a_request_with_body(body)
6
+ expect(a_request(:post, endpoint).with(body: body)).to have_been_made.once
7
+ end
8
+
9
+ let(:project_id) { 105138 }
10
+ let(:project_key) { 'fd04e13d806a90f96614ad8e529b2822' }
11
+ let(:localhost) { 'http://localhost:8080' }
12
+
13
+ let(:endpoint) do
14
+ "https://airbrake.io/api/v3/projects/#{project_id}/notices?key=#{project_key}"
15
+ end
16
+
17
+ let(:airbrake_params) do
18
+ { project_id: project_id,
19
+ project_key: project_key,
20
+ logger: Logger.new(StringIO.new) }
21
+ end
22
+
23
+ let(:ex) { AirbrakeTestError.new }
24
+
25
+ before do
26
+ stub_request(:post, endpoint).to_return(status: 201, body: '{}')
27
+ @airbrake = described_class.new(airbrake_params)
28
+ end
29
+
30
+ describe "#new" do
31
+ context "raises error if" do
32
+ example ":project_id is not provided" do
33
+ expect { described_class.new(project_id: project_id) }.
34
+ to raise_error(Airbrake::Error,
35
+ 'both :project_id and :project_key are required')
36
+ end
37
+
38
+ example ":project_key is not provided" do
39
+ expect { described_class.new(project_key: project_key) }.
40
+ to raise_error(Airbrake::Error,
41
+ 'both :project_id and :project_key are required')
42
+ end
43
+
44
+ example "neither :project_id nor :project_key are provided" do
45
+ expect { described_class.new({}) }.
46
+ to raise_error(Airbrake::Error,
47
+ 'both :project_id and :project_key are required')
48
+ end
49
+ end
50
+
51
+ context "when the argument is Airbrake::Config" do
52
+ it "uses it instead of the hash" do
53
+ airbrake = described_class.new(
54
+ Airbrake::Config.new(project_id: 123, project_key: '321')
55
+ )
56
+ config = airbrake.instance_variable_get(:@config)
57
+ expect(config.project_id).to eq(123)
58
+ expect(config.project_key).to eq('321')
59
+ end
60
+ end
61
+ end
62
+
63
+ describe "#notify_sync" do
64
+ describe "first argument" do
65
+ context "when it is a Notice" do
66
+ it "sends the argument" do
67
+ notice = @airbrake.build_notice(ex)
68
+ @airbrake.notify_sync(notice)
69
+
70
+ # rubocop:disable Metrics/LineLength
71
+ expected_body = %r|
72
+ {"errors":\[{"type":"AirbrakeTestError","message":"App\scrashed!","backtrace":\[
73
+ {"file":"[\w/-]+/spec/spec_helper.rb","line":\d+,"function":"<top\s\(required\)>"},
74
+ {"file":"[\w/\-\.]+/rubygems/core_ext/kernel_require\.rb","line":\d+,"function":"require"},
75
+ {"file":"[\w/\-\.]+/rubygems/core_ext/kernel_require\.rb","line":\d+,"function":"require"}
76
+ |x
77
+ # rubocop:enable Metrics/LineLength
78
+
79
+ expect(
80
+ a_request(:post, endpoint).
81
+ with(body: expected_body)
82
+ ).to have_been_made.once
83
+ end
84
+ end
85
+ end
86
+
87
+ describe "request" do
88
+ before do
89
+ @airbrake.notify_sync(ex, bingo: ['bango'], bongo: 'bish')
90
+ end
91
+
92
+ it "is being made over HTTPS" do
93
+ expect(
94
+ a_request(:post, endpoint).
95
+ with { |req| req.uri.port == 443 }
96
+ ).to have_been_made.once
97
+ end
98
+
99
+ describe "headers" do
100
+ def expect_a_request_with_headers(headers)
101
+ expect(
102
+ a_request(:post, endpoint).
103
+ with(headers: headers)
104
+ ).to have_been_made.once
105
+ end
106
+
107
+ it "POSTs JSON to Airbrake" do
108
+ expect_a_request_with_headers('Content-Type' => 'application/json')
109
+ end
110
+
111
+ it "sets User-Agent" do
112
+ ua = "airbrake-ruby/#{Airbrake::AIRBRAKE_RUBY_VERSION} Ruby/#{RUBY_VERSION}"
113
+ expect_a_request_with_headers('User-Agent' => ua)
114
+ end
115
+ end
116
+
117
+ describe "body" do
118
+ it "features 'notifier'" do
119
+ expect_a_request_with_body(/"notifier":{"name":"airbrake-ruby"/)
120
+ end
121
+
122
+ it "features 'context'" do
123
+ expect_a_request_with_body(/"context":{.*"os":"[\w-]+"/)
124
+ end
125
+
126
+ it "features 'errors'" do
127
+ expect_a_request_with_body(
128
+ /"errors":\[{"type":"AirbrakeTestError","message":"App crash/
129
+ )
130
+ end
131
+
132
+ it "features 'backtrace'" do
133
+ expect_a_request_with_body(
134
+ %r|"backtrace":\[{"file":"/home/.+/spec/spec_helper.rb"|
135
+ )
136
+ end
137
+
138
+ it "features 'params'" do
139
+ expect_a_request_with_body(
140
+ /"params":{"bingo":\["bango"\],"bongo":"bish"}/
141
+ )
142
+ end
143
+ end
144
+ end
145
+
146
+ describe "response body when it is" do
147
+ before do
148
+ @stdout = StringIO.new
149
+ params = {
150
+ logger: Logger.new(@stdout).tap { |l| l.level = Logger::DEBUG }
151
+ }
152
+ @airbrake = described_class.new(airbrake_params.merge(params))
153
+ end
154
+
155
+ shared_examples "HTTP codes" do |code, body, expected_output|
156
+ it "logs error #{code}" do
157
+ stub_request(:post, endpoint).to_return(status: code, body: body)
158
+
159
+ expect(@stdout.string).to be_empty
160
+
161
+ response = @airbrake.notify_sync(ex)
162
+
163
+ expect(@stdout.string).to match(expected_output)
164
+ expect(response).to be_a Hash
165
+
166
+ if response['error']
167
+ expect(response['error']).to satisfy do |error|
168
+ error.is_a?(Exception) || error.is_a?(String)
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ context "a hash with response and invalid status" do
175
+ include_examples 'HTTP codes', 200,
176
+ '{"id":"1","url":"https://airbrake.io/locate/1"}',
177
+ %r{unexpected code \(200\). Body: .+url":"https://airbrake.+}
178
+ end
179
+
180
+ context "an empty page" do
181
+ include_examples 'HTTP codes', 200, '',
182
+ /ERROR -- : .+ unexpected code \(200\). Body: \[EMPTY_BODY\]/
183
+ end
184
+
185
+ context "a valid body with code 201" do
186
+ include_examples 'HTTP codes', 201,
187
+ '{"id":"1","url":"https://airbrake.io/locate/1"}',
188
+ %r|DEBUG -- : .+url"=>"https://airbrake.io/locate/1"}|
189
+ end
190
+
191
+ context "a non-parseable page" do
192
+ include_examples 'HTTP codes', 400, 'bingo bango bongo',
193
+ /ERROR -- : .+unexpected token at 'bingo.+'\)\. Body: bingo.+/
194
+ end
195
+
196
+ context "error 400" do
197
+ include_examples 'HTTP codes', 400, '{"error": "Invalid Content-Type header."}',
198
+ /ERROR -- : .+ Invalid Content-Type header\./
199
+ end
200
+
201
+ context "error 401" do
202
+ include_examples 'HTTP codes', 401,
203
+ '{"error":"Project not found or access denied."}',
204
+ /ERROR -- : .+ Project not found or access denied./
205
+ end
206
+
207
+ context "the rate-limit message" do
208
+ include_examples 'HTTP codes', 429, '{"error": "Project is rate limited."}',
209
+ /ERROR -- : .+ Project is rate limited.+/
210
+ end
211
+
212
+ context "the internal server error" do
213
+ include_examples 'HTTP codes', 500, 'Internal Server Error',
214
+ /ERROR -- : .+ unexpected code \(500\). Body: Internal.+ Error/
215
+ end
216
+
217
+ context "too long it truncates it and" do
218
+ include_examples 'HTTP codes', 123, '123 ' * 1000,
219
+ /ERROR -- : .+ unexpected code \(123\). Body: .+ 123 123 1\.\.\./
220
+ end
221
+ end
222
+
223
+ describe "connection timeout" do
224
+ it "logs the error when it occurs" do
225
+ stub_request(:post, endpoint).to_timeout
226
+
227
+ stderr = StringIO.new
228
+ params = airbrake_params.merge(logger: Logger.new(stderr))
229
+ airbrake = described_class.new(params)
230
+
231
+ airbrake.notify_sync(ex)
232
+
233
+ expect(stderr.string).
234
+ to match(/ERROR -- : .+ HTTP error: execution expired/)
235
+ end
236
+ end
237
+
238
+ describe "unicode payload" do
239
+ context "with valid strings" do
240
+ it "works correctly" do
241
+ @airbrake.notify_sync(ex, unicode: "ü ö ä Ä Ü Ö ß привет €25.00 한글")
242
+
243
+ expect(
244
+ a_request(:post, endpoint).
245
+ with(body: /"unicode":"ü ö ä Ä Ü Ö ß привет €25.00 한글"/)
246
+ ).to have_been_made.once
247
+ end
248
+ end
249
+
250
+ context "with invalid strings" do
251
+ it "doesn't raise error when string has invalid encoding" do
252
+ expect do
253
+ @airbrake.notify_sync('bingo', bongo: "bango\xAE")
254
+ end.not_to raise_error
255
+ end
256
+
257
+ it "doesn't raise error when string has valid encoding, but invalid characters" do
258
+ # Shenanigans to get a bad ASCII-8BIT string. Direct conversion raises error.
259
+ encoded = Base64.encode64("\xD3\xE6\xBC\x9D\xBA").encode!('ASCII-8BIT')
260
+ bad_string = Base64.decode64(encoded)
261
+
262
+ expect do
263
+ @airbrake.notify_sync('bingo', bongo: bad_string)
264
+ end.not_to raise_error
265
+ end
266
+ end
267
+ end
268
+
269
+ describe "a closed IO object" do
270
+ context "outside of the Rails environment" do
271
+ it "is not getting truncated" do
272
+ @airbrake.notify_sync(ex, bingo: IO.new(0).tap(&:close))
273
+
274
+ expect(
275
+ a_request(:post, endpoint).with(body: /"bingo":"#<IO:0x.+>"/)
276
+ ).to have_been_made.once
277
+ end
278
+ end
279
+
280
+ context "inside the Rails environment" do
281
+ ##
282
+ # Instances of this class contain a closed IO object assigned to an instance
283
+ # variable. Normally, the JSON gem, which we depend on can parse closed IO
284
+ # objects. However, because ActiveSupport monkey-patches #to_json and calls
285
+ # #to_a on them, they raise IOError when we try to serialize them.
286
+ #
287
+ # @see https://goo.gl/0A3xNC
288
+ class ObjectWithIoIvars
289
+ def initialize
290
+ @bongo = Tempfile.new('bongo').tap(&:close)
291
+ end
292
+
293
+ # @raise [NotImplementedError] when inside a Rails environment
294
+ def to_json(*)
295
+ raise NotImplementedError
296
+ end
297
+ end
298
+
299
+ ##
300
+ # @see ObjectWithIoIvars
301
+ class ObjectWithNestedIoIvars
302
+ def initialize
303
+ @bish = ObjectWithIoIvars.new
304
+ end
305
+
306
+ # @see ObjectWithIoIvars#to_json
307
+ def to_json(*)
308
+ raise NotImplementedError
309
+ end
310
+ end
311
+
312
+ shared_examples 'truncation' do |params, expected|
313
+ it "filters it out" do
314
+ @airbrake.notify_sync(ex, params)
315
+
316
+ expect(
317
+ a_request(:post, endpoint).with(body: expected)
318
+ ).to have_been_made.once
319
+ end
320
+ end
321
+
322
+ context "which is an instance of" do
323
+ context "Tempfile" do
324
+ params = { bango: Tempfile.new('bongo').tap(&:close) }
325
+ include_examples 'truncation', params, /"bango":"#<(Temp)?file:0x.+>"/i
326
+ end
327
+
328
+ context "a non-IO class but with" do
329
+ context "IO ivars" do
330
+ params = { bongo: ObjectWithIoIvars.new }
331
+ include_examples 'truncation', params, /"bongo":".+ObjectWithIoIvars.+"/
332
+ end
333
+
334
+ context "a non-IO ivar, which contains an IO ivar itself" do
335
+ params = { bish: ObjectWithNestedIoIvars.new }
336
+ include_examples 'truncation', params, /"bish":".+ObjectWithNested.+"/
337
+ end
338
+ end
339
+ end
340
+
341
+ context "which is deeply nested inside a hash" do
342
+ params = { bingo: { bango: { bongo: ObjectWithIoIvars.new } } }
343
+ include_examples(
344
+ 'truncation',
345
+ params,
346
+ /"params":{"bingo":{"bango":{"bongo":".+ObjectWithIoIvars.+"}}}/
347
+ )
348
+ end
349
+
350
+ context "which is deeply nested inside an array" do
351
+ params = { bingo: [[ObjectWithIoIvars.new]] }
352
+ include_examples(
353
+ 'truncation',
354
+ params,
355
+ /"params":{"bingo":\[\[".+ObjectWithIoIvars.+"\]\]}/
356
+ )
357
+ end
358
+ end
359
+ end
360
+ end
361
+
362
+ describe "#notify" do
363
+ it "sends an exception asynchronously" do
364
+ @airbrake.notify(ex, bingo: 'bango')
365
+
366
+ sleep 1
367
+
368
+ expect_a_request_with_body(/params":{"bingo":"bango"}/)
369
+ end
370
+
371
+ it "returns nil" do
372
+ expect(@airbrake.notify(ex)).to be_nil
373
+ sleep 1
374
+ end
375
+
376
+ it "falls back to synchronous delivery when the async sender is dead" do
377
+ out = StringIO.new
378
+
379
+ airbrake = described_class.new(airbrake_params.merge(logger: Logger.new(out)))
380
+ airbrake.
381
+ instance_variable_get(:@async_sender).
382
+ instance_variable_get(:@workers).
383
+ list.
384
+ each(&:kill)
385
+
386
+ sleep 1
387
+
388
+ expect(airbrake.notify('bingo')).to be_nil
389
+ expect(out.string).to match(/falling back to sync delivery/)
390
+ end
391
+ end
392
+
393
+ describe "#add_filter" do
394
+ it "filters notices" do
395
+ @airbrake.add_filter do |notice|
396
+ if notice[:params][:password]
397
+ notice[:params][:password] = '[Filtered]'.freeze
398
+ end
399
+ end
400
+
401
+ @airbrake.notify_sync(ex, password: 's4kr4t')
402
+
403
+ expect(
404
+ a_request(:post, endpoint).
405
+ with(body: /params":{"password":"\[Filtered\]"}/)
406
+ ).to have_been_made.once
407
+ end
408
+
409
+ it "accepts multiple filters" do
410
+ [:bingo, :bongo, :bash].each do |key|
411
+ @airbrake.add_filter do |notice|
412
+ notice[:params][key] = '[Filtered]'.freeze if notice[:params][key]
413
+ end
414
+ end
415
+
416
+ @airbrake.notify_sync(ex, bingo: ['bango'], bongo: 'bish', bash: 'bosh')
417
+
418
+ # rubocop:disable Metrics/LineLength
419
+ body = /"params":{"bingo":"\[Filtered\]","bongo":"\[Filtered\]","bash":"\[Filtered\]"}/
420
+ # rubocop:enable Metrics/LineLength
421
+
422
+ expect(
423
+ a_request(:post, endpoint).
424
+ with(body: body)
425
+ ).to have_been_made.once
426
+ end
427
+
428
+ it "ignores all notices" do
429
+ @airbrake.add_filter(&:ignore!)
430
+
431
+ @airbrake.notify_sync(ex)
432
+
433
+ expect(a_request(:post, endpoint)).not_to have_been_made
434
+ end
435
+
436
+ it "ignores specific notices" do
437
+ @airbrake.add_filter do |notice|
438
+ notice.ignore! if notice[:errors][0][:type] == 'RuntimeError'
439
+ end
440
+
441
+ @airbrake.notify_sync(RuntimeError.new('Not caring!'))
442
+ expect(a_request(:post, endpoint)).not_to have_been_made
443
+
444
+ @airbrake.notify_sync(ex)
445
+ expect(a_request(:post, endpoint)).to have_been_made.once
446
+ end
447
+ end
448
+
449
+ describe "#blacklist_keys" do
450
+ describe "the list of arguments" do
451
+ it "accepts regexps" do
452
+ @airbrake.blacklist_keys(/\Abin/)
453
+
454
+ @airbrake.notify_sync(ex, bingo: 'bango')
455
+
456
+ expect(
457
+ a_request(:post, endpoint).
458
+ with(body: /"params":{"bingo":"\[Filtered\]"}/)
459
+ ).to have_been_made.once
460
+ end
461
+
462
+ it "accepts symbols" do
463
+ @airbrake.blacklist_keys(:bingo)
464
+
465
+ @airbrake.notify_sync(ex, bingo: 'bango')
466
+
467
+ expect(
468
+ a_request(:post, endpoint).
469
+ with(body: /"params":{"bingo":"\[Filtered\]"}/)
470
+ ).to have_been_made.once
471
+ end
472
+
473
+ it "accepts strings" do
474
+ @airbrake.blacklist_keys('bingo')
475
+
476
+ @airbrake.notify_sync(ex, bingo: 'bango')
477
+
478
+ expect(
479
+ a_request(:post, endpoint).
480
+ with(body: /"params":{"bingo":"\[Filtered\]"}/)
481
+ ).to have_been_made.once
482
+ end
483
+ end
484
+
485
+ describe "hash values" do
486
+ context "non-recursive" do
487
+ it "filters nested hashes" do
488
+ @airbrake.blacklist_keys('bish')
489
+
490
+ @airbrake.notify_sync(ex, bongo: { bish: 'bash' })
491
+
492
+ expect(
493
+ a_request(:post, endpoint).
494
+ with(body: /"params":{"bongo":{"bish":"\[Filtered\]"}}/)
495
+ ).to have_been_made.once
496
+ end
497
+ end
498
+
499
+ context "recursive" do
500
+ it "filters recursive hashes" do
501
+ @airbrake.blacklist_keys('bango')
502
+
503
+ bongo = { bingo: {} }
504
+ bongo[:bingo][:bango] = bongo
505
+
506
+ @airbrake.notify_sync(ex, bongo)
507
+
508
+ expect(
509
+ a_request(:post, endpoint).
510
+ with(body: /"params":{"bingo":{"bango":"\[Filtered\]"}}/)
511
+ ).to have_been_made.once
512
+ end
513
+ end
514
+ end
515
+
516
+ it "filters query parameters correctly" do
517
+ @airbrake.blacklist_keys(%w(bish))
518
+
519
+ notice = @airbrake.build_notice(ex)
520
+ notice[:context][:url] = 'http://localhost:3000/crash?foo=bar&baz=bongo&bish=bash'
521
+
522
+ @airbrake.notify_sync(notice)
523
+
524
+ # rubocop:disable Metrics/LineLength
525
+ expected_body =
526
+ %r("context":{.*"url":"http://localhost:3000/crash\?foo=bar&baz=bongo&bish=\[Filtered\]".*})
527
+ # rubocop:enable Metrics/LineLength
528
+
529
+ expect(
530
+ a_request(:post, endpoint).
531
+ with(body: expected_body)
532
+ ).to have_been_made.once
533
+ end
534
+ end
535
+
536
+ describe "#whitelist_keys" do
537
+ describe "the list of arguments" do
538
+ it "accepts regexes" do
539
+ @airbrake.whitelist_keys(/\Abin/)
540
+
541
+ @airbrake.notify_sync(ex, bingo: 'bango', bongo: 'bish', bash: 'bosh')
542
+
543
+ body = /"params":{"bingo":"bango","bongo":"\[Filtered\]","bash":"\[Filtered\]"}/
544
+
545
+ expect(
546
+ a_request(:post, endpoint).
547
+ with(body: body)
548
+ ).to have_been_made.once
549
+ end
550
+
551
+ it "accepts symbols" do
552
+ @airbrake.whitelist_keys(:bongo)
553
+
554
+ @airbrake.notify_sync(ex, bingo: 'bango', bongo: 'bish', bash: 'bosh')
555
+
556
+ body = /"params":{"bingo":"\[Filtered\]","bongo":"bish","bash":"\[Filtered\]"}/
557
+
558
+ expect(
559
+ a_request(:post, endpoint).
560
+ with(body: body)
561
+ ).to have_been_made.once
562
+ end
563
+
564
+ it "accepts strings" do
565
+ @airbrake.whitelist_keys('bash')
566
+
567
+ @airbrake.notify_sync(ex, bingo: 'bango', bongo: 'bish', bash: 'bosh')
568
+
569
+ body = /"params":{"bingo":"\[Filtered\]","bongo":"\[Filtered\]","bash":"bosh"}/
570
+
571
+ expect(
572
+ a_request(:post, endpoint).
573
+ with(body: body)
574
+ ).to have_been_made.once
575
+ end
576
+ end
577
+
578
+ describe "hash values" do
579
+ context "non-recursive" do
580
+ it "filters out everything but the provided keys" do
581
+ @airbrake.whitelist_keys(%w(bongo bish))
582
+
583
+ @airbrake.notify_sync(ex, bingo: 'bango', bongo: { bish: 'bash' })
584
+
585
+ expect(
586
+ a_request(:post, endpoint).
587
+ with(body: /"params":{"bingo":"\[Filtered\]","bongo":{"bish":"bash"}}/)
588
+ ).to have_been_made.once
589
+ end
590
+ end
591
+
592
+ context "recursive" do
593
+ it "errors when nested hashes are not filtered" do
594
+ @airbrake.whitelist_keys(%w(bingo bango))
595
+
596
+ bongo = { bingo: {} }
597
+ bongo[:bingo][:bango] = bongo
598
+
599
+ if RUBY_ENGINE == 'jruby'
600
+ # JRuby might raise two different exceptions, which represent the
601
+ # same thing. One is a Java exception, the other is a Ruby
602
+ # exception. It's probably a JRuby bug:
603
+ # https://github.com/jruby/jruby/issues/1903
604
+ begin
605
+ expect do
606
+ @airbrake.notify_sync(ex, bongo)
607
+ end.to raise_error(SystemStackError)
608
+ rescue RSpec::Expectations::ExpectationNotMetError
609
+ expect do
610
+ @airbrake.notify_sync(ex, bongo)
611
+ end.to raise_error(java.lang.StackOverflowError)
612
+ end
613
+ else
614
+ expect do
615
+ @airbrake.notify_sync(ex, bongo)
616
+ end.to raise_error(SystemStackError)
617
+ end
618
+ end
619
+ end
620
+ end
621
+
622
+ describe "context/url" do
623
+ it "filters query parameters correctly" do
624
+ @airbrake.whitelist_keys(%w(bish))
625
+
626
+ notice = @airbrake.build_notice(ex)
627
+ notice[:context][:url] = 'http://localhost:3000/crash?foo=bar&baz=bongo&bish=bash'
628
+
629
+ @airbrake.notify_sync(notice)
630
+
631
+ # rubocop:disable Metrics/LineLength
632
+ expected_body =
633
+ %r("context":{.*"url":"http://localhost:3000/crash\?foo=\[Filtered\]&baz=\[Filtered\]&bish=bash".*})
634
+ # rubocop:enable Metrics/LineLength
635
+
636
+ expect(
637
+ a_request(:post, endpoint).
638
+ with(body: expected_body)
639
+ ).to have_been_made.once
640
+ end
641
+ end
642
+ end
643
+
644
+ describe "#build_notice" do
645
+ it "builds a notice from exception" do
646
+ expect(@airbrake.build_notice(ex)).to be_an Airbrake::Notice
647
+ end
648
+ end
649
+
650
+ describe "#close" do
651
+ shared_examples 'close' do |method|
652
+ it "raises error" do
653
+ @airbrake.close
654
+ expect { method.call(@airbrake) }.
655
+ to raise_error(Airbrake::Error, /closed Airbrake instance/)
656
+ end
657
+ end
658
+
659
+ context "when using #notify" do
660
+ include_examples 'close', proc { |a| a.notify(AirbrakeTestError.new) }
661
+ end
662
+
663
+ context "when using #send_notice" do
664
+ include_examples 'close', proc { |a|
665
+ notice = a.build_notice(AirbrakeTestError.new)
666
+ a.send_notice(notice)
667
+ }
668
+ end
669
+
670
+ context "at program exit when it was closed manually" do
671
+ it "doesn't raise error", skip: RUBY_ENGINE == 'jruby' do
672
+ expect do
673
+ Process.wait(fork { described_class.new(airbrake_params) })
674
+ end.not_to raise_error
675
+ end
676
+ end
677
+ end
678
+
679
+ describe "#create_deploy" do
680
+ let(:deploy_endpoint) do
681
+ "https://airbrake.io/api/v4/projects/#{project_id}/deploys?key=#{project_key}"
682
+ end
683
+
684
+ it "sends a request to the deploy API" do
685
+ stub_request(:post, deploy_endpoint).to_return(status: 201, body: '{"id":"123"}')
686
+ @airbrake.create_deploy({})
687
+ expect(a_request(:post, deploy_endpoint)).to have_been_made.once
688
+ end
689
+ end
690
+ end