expo-push 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,550 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'expo-push'
5
+ require 'expo-push/too_many_messages_error'
6
+
7
+ class ExponentServerSdkTest < Minitest::Test
8
+ def setup
9
+ @mock = MiniTest::Mock.new
10
+ @response_mock = MiniTest::Mock.new
11
+ @client = Exponent::Push::Client.new(http_client: @mock)
12
+ @client_gzip = Exponent::Push::Client.new(http_client: @mock, gzip: true)
13
+ end
14
+
15
+ def test_send_messages_with_success
16
+ @response_mock.expect(:code, 200)
17
+ @response_mock.expect(:body, success_body.to_json)
18
+
19
+ @mock.expect(:post, @response_mock, client_args)
20
+
21
+ response = @client.send_messages(messages)
22
+ assert_equal(response.errors?, false)
23
+
24
+ @mock.verify
25
+ end
26
+
27
+ def test_send_messages_alternate_message_format_with_success
28
+ @response_mock.expect(:code, 200)
29
+ @response_mock.expect(:body, success_body.to_json)
30
+
31
+ alternate_messages = alternate_format_messages
32
+ @mock.expect(:post, @response_mock, alternative_client_args(alternate_messages))
33
+
34
+ response = @client.send_messages(alternate_messages)
35
+ assert_equal(response.errors?, false)
36
+
37
+ @mock.verify
38
+ end
39
+
40
+ def test_send_messages_with_gzip_success
41
+ @response_mock.expect(:code, 200)
42
+ @response_mock.expect(:body, success_body.to_json)
43
+
44
+ @mock.expect(:post, @response_mock, gzip_client_args)
45
+
46
+ response = @client_gzip.send_messages(messages)
47
+ assert_equal(response.errors?, false)
48
+
49
+ @mock.verify
50
+ end
51
+
52
+ def test_send_messages_with_empty_string_response_body
53
+ @response_mock.expect(:code, 400)
54
+ @response_mock.expect(:body, '')
55
+
56
+ @mock.expect(:post, @response_mock, client_args)
57
+
58
+ exception = assert_raises Exponent::Push::UnknownError do
59
+ handler = @client.send_messages(messages)
60
+ # this first assertion is just stating that errors will be false when
61
+ # an exception is thrown on the request, not the content of the request
62
+ # 400/500 level errors are not delivery errors, they are functionality errors
63
+ assert_equal(handler.response.errors?, false)
64
+ assert_equal(handler.response.body, {})
65
+ assert_equal(handler.response.code, 400)
66
+ end
67
+
68
+ assert_match(/Unknown error format/, exception.message)
69
+
70
+ @mock.verify
71
+ end
72
+
73
+ def test_send_messages_with_nil_response_body
74
+ @response_mock.expect(:code, 400)
75
+ @response_mock.expect(:body, nil)
76
+
77
+ @mock.expect(:post, @response_mock, client_args)
78
+
79
+ exception = assert_raises Exponent::Push::UnknownError do
80
+ handler = @client.send_messages(messages)
81
+ # this first assertion is just stating that errors will be false when
82
+ # an exception is thrown on the request, not the content of the request
83
+ # 400/500 level errors are not delivery errors, they are functionality errors
84
+ assert_equal(handler.response.errors?, false)
85
+ assert_equal(handler.response.body, {})
86
+ assert_equal(handler.response.code, 400)
87
+ end
88
+
89
+ assert_match(/Unknown error format/, exception.message)
90
+
91
+ @mock.verify
92
+ end
93
+
94
+ def test_send_messages_with_gzip_empty_string_response
95
+ @response_mock.expect(:code, 400)
96
+ @response_mock.expect(:body, '')
97
+
98
+ @mock.expect(:post, @response_mock, gzip_client_args)
99
+
100
+ exception = assert_raises Exponent::Push::UnknownError do
101
+ handler = @client_gzip.send_messages(messages)
102
+ # this first assertion is just stating that errors will be false when
103
+ # an exception is thrown on the request, not the content of the request
104
+ # 400/500 level errors are not delivery errors, they are functionality errors
105
+ assert_equal(handler.response.errors?, false)
106
+ assert_equal(handler.response.body, {})
107
+ assert_equal(handler.response.code, 400)
108
+ end
109
+
110
+ assert_match(/Unknown error format/, exception.message)
111
+
112
+ @mock.verify
113
+ end
114
+
115
+ def test_send_messages_with_gzip_nil_response_body
116
+ @response_mock.expect(:code, 400)
117
+ @response_mock.expect(:body, nil)
118
+
119
+ @mock.expect(:post, @response_mock, gzip_client_args)
120
+
121
+ exception = assert_raises Exponent::Push::UnknownError do
122
+ handler = @client_gzip.send_messages(messages)
123
+ # this first assertion is just stating that errors will be false when
124
+ # an exception is thrown on the request, not the content of the request
125
+ # 400/500 level errors are not delivery errors, they are functionality errors
126
+ assert_equal(handler.response.errors?, false)
127
+ assert_equal(handler.response.body, {})
128
+ assert_equal(handler.response.code, 400)
129
+ end
130
+
131
+ assert_match(/Unknown error format/, exception.message)
132
+
133
+ @mock.verify
134
+ end
135
+
136
+ def test_send_messages_with_unknown_error
137
+ @response_mock.expect(:code, 400)
138
+ @response_mock.expect(:body, error_body.to_json)
139
+
140
+ @mock.expect(:post, @response_mock, client_args)
141
+
142
+ exception = assert_raises Exponent::Push::UnknownError do
143
+ @client.send_messages(messages)
144
+ end
145
+
146
+ assert_equal("Unknown error format: #{error_body.to_json}", exception.message)
147
+
148
+ @mock.verify
149
+ end
150
+
151
+ def test_send_messages_with_gzip_unknown_error
152
+ @response_mock.expect(:code, 400)
153
+ @response_mock.expect(:body, error_body.to_json)
154
+
155
+ @mock.expect(:post, @response_mock, gzip_client_args)
156
+
157
+ exception = assert_raises Exponent::Push::UnknownError do
158
+ @client_gzip.send_messages(messages)
159
+ end
160
+
161
+ assert_match(/Unknown error format/, exception.message)
162
+
163
+ @mock.verify
164
+ end
165
+
166
+ def test_send_messages_with_device_not_registered_error
167
+ @response_mock.expect(:code, 200)
168
+ @response_mock.expect(:body, not_registered_device_error_body.to_json)
169
+ token = 'ExponentPushToken[42]'
170
+ message = "\"#{token}\" is not a registered push notification recipient"
171
+
172
+ @mock.expect(:post, @response_mock, client_args)
173
+
174
+ response_handler = @client.send_messages(messages)
175
+ assert_equal(message, response_handler.errors.first.message)
176
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::DeviceNotRegisteredError))
177
+ assert(response_handler.invalid_push_tokens.include?(token))
178
+ assert(response_handler.errors?)
179
+
180
+ @mock.verify
181
+ end
182
+
183
+ def test_send_messages_too_many_messages
184
+ message = 'Only 100 message objects at a time allowed.'
185
+
186
+ e = assert_raises TooManyMessagesError do
187
+ @client.send_messages(too_many_messages)
188
+ end
189
+
190
+ assert_equal(e.message, message)
191
+ end
192
+
193
+ def test_send_messages_with_message_too_big_error
194
+ @response_mock.expect(:code, 200)
195
+ @response_mock.expect(:body, message_too_big_error_body.to_json)
196
+ message = 'Message too big'
197
+
198
+ @mock.expect(:post, @response_mock, client_args)
199
+
200
+ response_handler = @client.send_messages(messages)
201
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::MessageTooBigError))
202
+ assert_equal(message, response_handler.errors.first.message)
203
+ assert(response_handler.errors?)
204
+
205
+ @mock.verify
206
+ end
207
+
208
+ def test_send_messages_with_message_rate_exceeded_error
209
+ @response_mock.expect(:code, 200)
210
+ @response_mock.expect(:body, message_rate_exceeded_error_body.to_json)
211
+ message = 'Message rate exceeded'
212
+
213
+ @mock.expect(:post, @response_mock, client_args)
214
+
215
+ response_handler = @client.send_messages(messages)
216
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::MessageRateExceededError))
217
+ assert_equal(message, response_handler.errors.first.message)
218
+
219
+ @mock.verify
220
+ end
221
+
222
+ def test_send_messages_with_invalid_credentials_error
223
+ @response_mock.expect(:code, 200)
224
+ @response_mock.expect(:body, invalid_credentials_error_body.to_json)
225
+ message = 'Invalid credentials'
226
+
227
+ @mock.expect(:post, @response_mock, client_args)
228
+
229
+ response_handler = @client.send_messages(messages)
230
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::InvalidCredentialsError))
231
+ assert_equal(message, response_handler.errors.first.message)
232
+
233
+ @mock.verify
234
+ end
235
+
236
+ def test_send_messages_with_apn_error
237
+ @response_mock.expect(:code, 200)
238
+ @response_mock.expect(:body, apn_error_body.to_json)
239
+
240
+ @mock.expect(:post, @response_mock, client_args)
241
+
242
+ response_handler = @client.send_messages(messages)
243
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::UnknownError))
244
+ assert_match(/Unknown error format/, response_handler.errors.first.message)
245
+
246
+ @mock.verify
247
+ end
248
+
249
+ def test_get_receipts_with_success_receipt
250
+ @response_mock.expect(:code, 200)
251
+ @response_mock.expect(:body, receipt_success_body.to_json)
252
+ receipt_ids = [success_receipt]
253
+
254
+ @mock.expect(:post, @response_mock, receipt_client_args(receipt_ids))
255
+
256
+ response_handler = @client.verify_deliveries(receipt_ids)
257
+ assert_match(success_receipt, response_handler.receipt_ids.first)
258
+
259
+ @mock.verify
260
+ end
261
+
262
+ def test_get_receipts_with_error_receipt
263
+ @response_mock.expect(:code, 200)
264
+ @response_mock.expect(:body, receipt_error_body.to_json)
265
+ receipt_ids = [error_receipt]
266
+
267
+ @mock.expect(:post, @response_mock, receipt_client_args(receipt_ids))
268
+
269
+ response_handler = @client.verify_deliveries(receipt_ids)
270
+ assert_match(error_receipt, response_handler.receipt_ids.first)
271
+ assert_equal(true, response_handler.errors?)
272
+ assert_equal(1, response_handler.errors.count)
273
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::DeviceNotRegisteredError))
274
+
275
+ @mock.verify
276
+ end
277
+
278
+ def test_get_receipts_with_variable_success_receipts
279
+ @response_mock.expect(:code, 200)
280
+ @response_mock.expect(:body, multiple_receipts.to_json)
281
+ receipt_ids = [error_receipt, success_receipt]
282
+
283
+ @mock.expect(:post, @response_mock, receipt_client_args(receipt_ids))
284
+
285
+ response_handler = @client.verify_deliveries(receipt_ids)
286
+ assert_match(error_receipt, response_handler.receipt_ids.first)
287
+ assert_match(success_receipt, response_handler.receipt_ids.last)
288
+ assert_equal(true, response_handler.errors?)
289
+ assert_equal(1, response_handler.errors.count)
290
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::DeviceNotRegisteredError))
291
+
292
+ @mock.verify
293
+ end
294
+
295
+ def test_get_receipts_with_gzip_success_receipt
296
+ @response_mock.expect(:code, 200)
297
+ @response_mock.expect(:body, receipt_success_body.to_json)
298
+ receipt_ids = [success_receipt]
299
+
300
+ @mock.expect(:post, @response_mock, gzip_receipt_client_args(receipt_ids))
301
+
302
+ response_handler = @client_gzip.verify_deliveries(receipt_ids)
303
+ assert_match(success_receipt, response_handler.receipt_ids.first)
304
+
305
+ @mock.verify
306
+ end
307
+
308
+ def test_get_receipts_with_gzip_error_receipt
309
+ @response_mock.expect(:code, 200)
310
+ @response_mock.expect(:body, receipt_error_body.to_json)
311
+ receipt_ids = [error_receipt]
312
+
313
+ @mock.expect(:post, @response_mock, gzip_receipt_client_args(receipt_ids))
314
+
315
+ response_handler = @client_gzip.verify_deliveries(receipt_ids)
316
+ assert_match(error_receipt, response_handler.receipt_ids.first)
317
+ assert_equal(true, response_handler.errors?)
318
+ assert_equal(1, response_handler.errors.count)
319
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::DeviceNotRegisteredError))
320
+
321
+ @mock.verify
322
+ end
323
+
324
+ def test_get_receipts_with_gzip_variable_success_receipts
325
+ @response_mock.expect(:code, 200)
326
+ @response_mock.expect(:body, multiple_receipts.to_json)
327
+ receipt_ids = [error_receipt, success_receipt]
328
+
329
+ @mock.expect(:post, @response_mock, gzip_receipt_client_args(receipt_ids))
330
+
331
+ response_handler = @client_gzip.verify_deliveries(receipt_ids)
332
+ assert_match(error_receipt, response_handler.receipt_ids.first)
333
+ assert_match(success_receipt, response_handler.receipt_ids.last)
334
+ assert_equal(true, response_handler.errors?)
335
+ assert_equal(1, response_handler.errors.count)
336
+ assert(response_handler.errors.first.instance_of?(Exponent::Push::DeviceNotRegisteredError))
337
+
338
+ @mock.verify
339
+ end
340
+
341
+ private
342
+
343
+ def success_body
344
+ { 'data' => [{ 'status' => 'ok' }] }
345
+ end
346
+
347
+ def success_receipt
348
+ 'YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY'
349
+ end
350
+
351
+ def error_receipt
352
+ 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'
353
+ end
354
+
355
+ def receipt_success_body
356
+ {
357
+ 'data' => {
358
+ 'YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY' => {
359
+ 'status' => 'ok'
360
+ }
361
+ }
362
+ }
363
+ end
364
+
365
+ def receipt_error_body
366
+ {
367
+ 'data' => {
368
+ 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' => {
369
+ 'status' => 'error',
370
+ 'message' => 'The Apple Push Notification service failed to send the notification',
371
+ 'details' => {
372
+ 'error' => 'DeviceNotRegistered'
373
+ }
374
+ }
375
+ }
376
+ }
377
+ end
378
+
379
+ def multiple_receipts
380
+ {
381
+ 'data' => {
382
+ 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' => {
383
+ 'status' => 'error',
384
+ 'message' => 'The Apple Push Notification service failed to send the notification',
385
+ 'details' => {
386
+ 'error' => 'DeviceNotRegistered'
387
+ }
388
+ },
389
+ 'YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY' => {
390
+ 'status' => 'ok'
391
+ }
392
+ }
393
+ }
394
+ end
395
+
396
+ def error_body
397
+ {
398
+ 'errors' => [{
399
+ 'code' => 'INTERNAL_SERVER_ERROR',
400
+ 'message' => 'An unknown error occurred.'
401
+ }]
402
+ }
403
+ end
404
+
405
+ def message_too_big_error_body
406
+ build_error_body('MessageTooBig', 'Message too big')
407
+ end
408
+
409
+ def not_registered_device_error_body
410
+ build_error_body(
411
+ 'DeviceNotRegistered',
412
+ '"ExponentPushToken[42]" is not a registered push notification recipient'
413
+ )
414
+ end
415
+
416
+ def message_rate_exceeded_error_body
417
+ build_error_body('MessageRateExceeded', 'Message rate exceeded')
418
+ end
419
+
420
+ def invalid_credentials_error_body
421
+ build_error_body('InvalidCredentials', 'Invalid credentials')
422
+ end
423
+
424
+ def apn_error_body
425
+ {
426
+ 'data' => [{
427
+ 'status' => 'error',
428
+ 'message' =>
429
+ 'Could not find APNs credentials for you (your_app). Check whether you are trying to send a notification to a detached app.'
430
+ }]
431
+ }
432
+ end
433
+
434
+ def client_args
435
+ [
436
+ 'https://exp.host/--/api/v2/push/send',
437
+ {
438
+ body: messages.to_json,
439
+ headers: {
440
+ 'Content-Type' => 'application/json',
441
+ 'Accept' => 'application/json'
442
+ },
443
+ accept_encoding: false
444
+ }
445
+ ]
446
+ end
447
+
448
+ def alternative_client_args(messages)
449
+ [
450
+ 'https://exp.host/--/api/v2/push/send',
451
+ {
452
+ body: messages.to_json,
453
+ headers: {
454
+ 'Content-Type' => 'application/json',
455
+ 'Accept' => 'application/json'
456
+ },
457
+ accept_encoding: false
458
+ }
459
+ ]
460
+ end
461
+
462
+ def gzip_client_args
463
+ [
464
+ 'https://exp.host/--/api/v2/push/send',
465
+ {
466
+ body: messages.to_json,
467
+ headers: {
468
+ 'Content-Type' => 'application/json',
469
+ 'Accept' => 'application/json'
470
+ },
471
+ accept_encoding: true
472
+ }
473
+ ]
474
+ end
475
+
476
+ def receipt_client_args(receipt_ids)
477
+ [
478
+ 'https://exp.host/--/api/v2/push/getReceipts',
479
+ {
480
+ body: { ids: receipt_ids }.to_json,
481
+ headers: {
482
+ 'Content-Type' => 'application/json',
483
+ 'Accept' => 'application/json'
484
+ },
485
+ accept_encoding: false
486
+ }
487
+ ]
488
+ end
489
+
490
+ def gzip_receipt_client_args(receipt_ids)
491
+ [
492
+ 'https://exp.host/--/api/v2/push/getReceipts',
493
+ {
494
+ body: { ids: receipt_ids }.to_json,
495
+ headers: {
496
+ 'Content-Type' => 'application/json',
497
+ 'Accept' => 'application/json'
498
+ },
499
+ accept_encoding: true
500
+ }
501
+ ]
502
+ end
503
+
504
+ def alternate_format_messages
505
+ [{
506
+ to: [
507
+ 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]',
508
+ 'ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]'
509
+ ],
510
+ badge: 1,
511
+ sound: 'default',
512
+ body: 'You got a completely unique message from us! /s'
513
+ }]
514
+ end
515
+
516
+ def messages
517
+ [{
518
+ to: 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]',
519
+ sound: 'default',
520
+ body: 'Hello world!'
521
+ }, {
522
+ to: 'ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]',
523
+ badge: 1,
524
+ body: "You've got mail"
525
+ }]
526
+ end
527
+
528
+ def too_many_messages
529
+ (0..101).map { create_message }
530
+ end
531
+
532
+ def create_message
533
+ id = (0...22).map { ('a'..'z').to_a[rand(26)] }.join
534
+ {
535
+ to: "ExponentPushToken[#{id}]",
536
+ sound: 'default',
537
+ body: 'Hello world!'
538
+ }
539
+ end
540
+
541
+ def build_error_body(error_code, message)
542
+ {
543
+ 'data' => [{
544
+ 'status' => 'error',
545
+ 'message' => message,
546
+ 'details' => { 'error' => error_code }
547
+ }]
548
+ }
549
+ end
550
+ end
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: expo-push
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Jesse Ruder
8
+ - Pablo Gomez
9
+ - Mike Taylor
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2022-11-01 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: typhoeus
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - "~>"
20
+ - !ruby/object:Gem::Version
21
+ version: '1.4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '1.4'
29
+ - !ruby/object:Gem::Dependency
30
+ name: bundler
31
+ requirement: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: '0'
36
+ type: :development
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ - !ruby/object:Gem::Dependency
44
+ name: minitest
45
+ requirement: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ type: :development
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ - !ruby/object:Gem::Dependency
58
+ name: rake
59
+ requirement: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ - !ruby/object:Gem::Dependency
72
+ name: rubocop
73
+ requirement: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ type: :development
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ description: A ruby gem to communicate with the Expo Push service.
86
+ email:
87
+ - jesse@sixfivezero.net
88
+ - pablonahuelgomez@gmail.com
89
+ - mike.taylor@growmaple.com
90
+ executables: []
91
+ extensions: []
92
+ extra_rdoc_files: []
93
+ files:
94
+ - ".gitignore"
95
+ - ".rubocop.yml"
96
+ - ".travis.yml"
97
+ - Gemfile
98
+ - LICENSE.txt
99
+ - README.md
100
+ - Rakefile
101
+ - examples/getting_started.rb
102
+ - expo-push.gemspec
103
+ - lib/expo-push.rb
104
+ - lib/expo-push/too_many_messages_error.rb
105
+ - lib/expo-push/version.rb
106
+ - test/expo-push-test.rb
107
+ homepage: https://github.com/growmaple/expo-push
108
+ licenses:
109
+ - MIT
110
+ metadata: {}
111
+ post_install_message:
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.1.6
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Expo Push
130
+ test_files:
131
+ - test/expo-push-test.rb