airbrake-ruby 1.0.0.rc.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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