pushover 3.0.2 → 4.0.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +28 -0
  3. data/.gitignore +1 -0
  4. data/.rubocop.yml +11 -24
  5. data/.ruby-version +1 -0
  6. data/CONVENTIONS.md +19 -0
  7. data/Gemfile +1 -2
  8. data/Guardfile +3 -3
  9. data/README.md +236 -44
  10. data/Rakefile +9 -59
  11. data/exe/pushover +6 -5
  12. data/lib/pushover/client.rb +53 -0
  13. data/lib/pushover/glances.rb +86 -0
  14. data/lib/pushover/groups.rb +137 -0
  15. data/lib/pushover/licenses.rb +67 -0
  16. data/lib/pushover/limits.rb +18 -0
  17. data/lib/pushover/message.rb +29 -32
  18. data/lib/pushover/message_encryption.rb +48 -0
  19. data/lib/pushover/message_validator.rb +167 -0
  20. data/lib/pushover/messages.rb +20 -0
  21. data/lib/pushover/receipt.rb +23 -10
  22. data/lib/pushover/receipts.rb +60 -0
  23. data/lib/pushover/response.rb +8 -3
  24. data/lib/pushover/sounds.rb +18 -0
  25. data/lib/pushover/subscriptions.rb +48 -0
  26. data/lib/pushover/teams.rb +93 -0
  27. data/lib/pushover/users.rb +37 -0
  28. data/lib/pushover/version.rb +1 -1
  29. data/lib/pushover.rb +14 -1
  30. data/pushover.gemspec +9 -7
  31. data/spec/gem/specification_spec.rb +19 -0
  32. data/spec/pushover/client_spec.rb +149 -0
  33. data/spec/pushover/glances_spec.rb +206 -0
  34. data/spec/pushover/groups_spec.rb +486 -0
  35. data/spec/pushover/licenses_spec.rb +238 -0
  36. data/spec/pushover/limits_spec.rb +92 -0
  37. data/spec/pushover/message_spec.rb +146 -26
  38. data/spec/pushover/messages_spec.rb +199 -0
  39. data/spec/pushover/receipt_spec.rb +119 -11
  40. data/spec/pushover/receipts_spec.rb +232 -0
  41. data/spec/pushover/response_spec.rb +21 -16
  42. data/spec/pushover/sounds_spec.rb +92 -0
  43. data/spec/pushover/subscriptions_spec.rb +185 -0
  44. data/spec/pushover/teams_spec.rb +325 -0
  45. data/spec/pushover/users_spec.rb +115 -0
  46. data/spec/spec_helper.rb +2 -3
  47. metadata +54 -18
  48. data/.travis.yml +0 -8
@@ -0,0 +1,199 @@
1
+ require 'base64'
2
+ require 'spec_helper'
3
+
4
+ describe Pushover::Messages do
5
+ subject(:messages) { Pushover::Client.new(token: 'app-token').messages }
6
+
7
+ after { Excon.stubs.clear }
8
+
9
+ describe '#create' do
10
+ context 'with a valid message' do
11
+ let(:request) { {} }
12
+ let(:response) { messages.create(user: 'user-key', message: 'Hello', title: 'Greeting', html: true) }
13
+
14
+ before do
15
+ captured_request = request
16
+ Excon.stub(method: :post, path: '/1/messages.json') do |params|
17
+ captured_request.replace(params)
18
+ { body: Oj.dump(status: 1, request: 'request-id'), headers: { 'X-Limit-App-Remaining' => '9' }, status: 200 }
19
+ end
20
+ end
21
+
22
+ it 'posts all fields as JSON' do
23
+ response
24
+
25
+ expect(Oj.strict_load(request[:body])).to eq(
26
+ 'token' => 'app-token', 'user' => 'user-key', 'message' => 'Hello', 'title' => 'Greeting', 'html' => 1
27
+ )
28
+ end
29
+
30
+ it 'uses the JSON content type' do
31
+ response
32
+
33
+ expect(request[:headers]).to include('Content-Type' => 'application/json')
34
+ end
35
+
36
+ it 'returns a response' do
37
+ expect(response).to have_attributes(status: 1, request: 'request-id')
38
+ end
39
+
40
+ it 'preserves response headers' do
41
+ expect(response.headers).to include('X-Limit-App-Remaining' => '9')
42
+ end
43
+ end
44
+
45
+ it 'returns Pushover errors from a 4xx response' do
46
+ Excon.stub(method: :post, path: '/1/messages.json') do |_params|
47
+ { body: Oj.dump(status: 0, errors: ['user identifier is invalid']), status: 400 }
48
+ end
49
+
50
+ response = messages.create(user: 'invalid', message: 'Hello')
51
+
52
+ expect(response).to have_attributes(status: 0, errors: ['user identifier is invalid'])
53
+ end
54
+
55
+ def stub_success
56
+ Excon.stub(
57
+ { method: :post, path: '/1/messages.json' },
58
+ { body: Oj.dump(status: 1, request: 'request-id'), status: 200 }
59
+ )
60
+ end
61
+
62
+ %i[user message].each do |parameter|
63
+ it "requires #{parameter}" do
64
+ params = { user: 'user-key', message: 'Hello' }
65
+ params[parameter] = ''
66
+
67
+ expect { messages.create(**params) }.to raise_error ArgumentError, /#{parameter} must be supplied/
68
+ end
69
+ end
70
+
71
+ {
72
+ message: 1024,
73
+ title: 250,
74
+ url: 512,
75
+ url_title: 100
76
+ }.each do |parameter, maximum|
77
+ it "limits #{parameter} to #{maximum} characters" do
78
+ params = { user: 'user-key', message: 'Hello', parameter => 'a' * (maximum + 1) }
79
+
80
+ expect { messages.create(**params) }.to raise_error ArgumentError, /#{parameter} must be at most #{maximum} characters/
81
+ end
82
+ end
83
+
84
+ it 'limits one request to 50 users' do
85
+ users = Array.new(51, 'user-key').join(',')
86
+
87
+ expect { messages.create(user: users, message: 'Hello') }.to raise_error ArgumentError, /at most 50 users/
88
+ end
89
+
90
+ it 'validates device names' do
91
+ expect do
92
+ messages.create(user: 'user-key', message: 'Hello', device: 'not a valid device')
93
+ end.to raise_error ArgumentError, /device contains an invalid name/
94
+ end
95
+
96
+ it 'accepts only documented priorities' do
97
+ expect do
98
+ messages.create(user: 'user-key', message: 'Hello', priority: 3)
99
+ end.to raise_error ArgumentError, /priority must be one of/
100
+ end
101
+
102
+ it 'requires retry and expire for emergency priority' do
103
+ expect do
104
+ messages.create(user: 'user-key', message: 'Hello', priority: 2)
105
+ end.to raise_error ArgumentError, /retry and expire must be supplied/
106
+ end
107
+
108
+ it 'requires emergency retries of at least 30 seconds' do
109
+ expect do
110
+ messages.create(user: 'user-key', message: 'Hello', priority: 2, retry: 29, expire: 10_801)
111
+ end.to raise_error ArgumentError, /retry must be at least 30 seconds/
112
+ end
113
+
114
+ it 'limits emergency expiration to 10800 seconds' do
115
+ expect do
116
+ messages.create(user: 'user-key', message: 'Hello', priority: 2, retry: 30, expire: 10_801)
117
+ end.to raise_error ArgumentError, /expire must be at most 10800 seconds/
118
+ end
119
+
120
+ it 'rejects emergency-only fields for other priorities' do
121
+ expect do
122
+ messages.create(user: 'user-key', message: 'Hello', callback: 'https://example.test/callback')
123
+ end.to raise_error ArgumentError, /callback is only valid with priority 2/
124
+ end
125
+
126
+ it 'requires an HTTP or HTTPS callback URL' do
127
+ expect do
128
+ messages.create(user: 'user-key', message: 'Hello', priority: 2, retry: 30, expire: 60, callback: 'file:///tmp/callback')
129
+ end.to raise_error ArgumentError, /callback must be an HTTP or HTTPS URL/
130
+ end
131
+
132
+ it 'rejects a malformed callback URL' do
133
+ expect do
134
+ messages.create(user: 'user-key', message: 'Hello', priority: 2, retry: 30, expire: 60, callback: 'http://[')
135
+ end.to raise_error ArgumentError, /callback must be an HTTP or HTTPS URL/
136
+ end
137
+
138
+ it 'does not allow HTML and monospace formatting together' do
139
+ expect do
140
+ messages.create(user: 'user-key', message: 'Hello', html: true, monospace: true)
141
+ end.to raise_error ArgumentError, /html and monospace cannot both be enabled/
142
+ end
143
+
144
+ it 'requires a positive TTL' do
145
+ expect do
146
+ messages.create(user: 'user-key', message: 'Hello', ttl: 0)
147
+ end.to raise_error ArgumentError, /ttl must be a positive integer/
148
+ end
149
+
150
+ it 'requires valid Base64 attachment data' do
151
+ expect do
152
+ messages.create(user: 'user-key', message: 'Hello', attachment_base64: 'not-base64', attachment_type: 'image/png')
153
+ end.to raise_error ArgumentError, /attachment_base64 must be valid Base64/
154
+ end
155
+
156
+ it 'limits decoded attachments to 5 MiB' do
157
+ oversized = Base64.strict_encode64('a' * 5_242_881)
158
+
159
+ expect do
160
+ messages.create(user: 'user-key', message: 'Hello', attachment_base64: oversized, attachment_type: 'image/png')
161
+ end.to raise_error ArgumentError, /attachment must not exceed 5242880 bytes/
162
+ end
163
+
164
+ it 'requires attachment data and MIME type together' do
165
+ expect do
166
+ messages.create(user: 'user-key', message: 'Hello', attachment_base64: Base64.strict_encode64('image'))
167
+ end.to raise_error ArgumentError, /attachment_type must be supplied/
168
+ end
169
+
170
+ context 'with end-to-end encryption' do
171
+ let(:request) { {} }
172
+ let(:key) { '00' * 32 }
173
+
174
+ before do
175
+ captured_request = request
176
+ allow(SecureRandom).to receive(:random_bytes).and_return("\x01" * 16)
177
+ Excon.stub(method: :post, path: '/1/messages.json') do |params|
178
+ captured_request.replace(Oj.strict_load(params[:body]))
179
+ { body: Oj.dump(status: 1, request: 'request-id'), status: 200 }
180
+ end
181
+ messages.create(user: 'user-key', message: 'secret', title: 'private', encryption_key: key)
182
+ end
183
+
184
+ it 'uses the documented deterministic wire format' do
185
+ expect(request.slice('encrypted', 'message', 'title')).to eq(
186
+ 'encrypted' => 1,
187
+ 'message' => 'AQEBAQEBAQEBAQEBAQEBAU0FqlkYIHEPzHs0NklFY9QT8ZekMQAM/J6iku1rmOJcEcm1SgT5ASVxoQW5SUFLnyWhe0y1Tp7q0YmS2iTpk4k=',
188
+ 'title' => 'AQEBAQEBAQEBAQEBAQEBAWWaD5zPUESFUncs7Hm+DBMTqpHx+n5zEHeQcvJiY+/LX/xd2rJVGXcosjBT23uEQ1TagEXt9wehyXozQxxYguw='
189
+ )
190
+ end
191
+ end
192
+
193
+ it 'requires a 64-character hexadecimal encryption key' do
194
+ expect do
195
+ messages.create(user: 'user-key', message: 'Hello', encryption_key: 'invalid')
196
+ end.to raise_error ArgumentError, /encryption_key must be 64 hexadecimal characters/
197
+ end
198
+ end
199
+ end
@@ -1,24 +1,132 @@
1
+ require 'open3'
2
+ require 'rbconfig'
1
3
  require 'spec_helper'
2
4
 
3
5
  describe Pushover::Receipt do
4
- it { is_expected.to have_attributes(receipt: a_kind_of(String).or(be_nil)) }
5
- it { is_expected.to have_attributes(token: a_kind_of(String).or(be_nil)) }
6
+ subject(:legacy_receipt) { described_class.new(receipt: receipt, token: token) }
6
7
 
7
- it { is_expected.to respond_to(:get).with(0).argument }
8
+ let(:receipt) { 'r' * 30 }
9
+ let(:token) { 'app-token' }
10
+ let(:standalone_script) do
11
+ <<~'RUBY'
12
+ require 'excon'
13
+ Excon.defaults[:mock] = true
14
+ require 'pushover/receipt'
15
+ receipt = 'r' * 30
16
+ Excon.stub(method: :get, path: "/1/receipts/#{receipt}.json", query: { token: 'app-token' }) do
17
+ { body: Oj.dump('status' => 1), status: 200 }
18
+ end
19
+ abort unless Pushover::Receipt.new(token: 'app-token', receipt: receipt).get.status == 1
20
+ RUBY
21
+ end
22
+
23
+ after { Excon.stubs.clear }
24
+
25
+ it 'supports the standalone legacy require path' do
26
+ _stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-e', standalone_script)
27
+
28
+ expect(status).to be_success, stderr
29
+ end
30
+
31
+ it 'uses an explicit compatibility class' do
32
+ expect(described_class.superclass).to eq(Object)
33
+ end
34
+
35
+ it 'accepts the legacy keyword form' do
36
+ instance = described_class.new(receipt: receipt, token: token)
37
+
38
+ expect(instance).to have_attributes(receipt: receipt, token: token)
39
+ end
40
+
41
+ it 'accepts a single Hash with string keys' do
42
+ instance = described_class.new('receipt' => receipt, 'token' => token)
43
+
44
+ expect(instance).to have_attributes(receipt: receipt, token: token)
45
+ end
46
+
47
+ it 'keeps the legacy members writable' do
48
+ legacy_receipt.receipt = 'a' * 30
8
49
 
9
- describe "#push" do
10
- let(:params) { { 'token' => 't', 'receipt' => 'receipt' } }
50
+ expect(legacy_receipt.receipt).to eq('a' * 30)
51
+ end
52
+
53
+ it 'rejects unknown members' do
54
+ expect { described_class.new(unknown: 'value') }.to raise_error ArgumentError
55
+ end
11
56
 
12
- it "is expected to send" do
13
- expect { described_class.new(params).get }.to raise_error Excon::Error::StubNotFound
57
+ it 'rejects non-Hash positional attributes' do
58
+ [nil, false, 1, 'invalid'].each do |attributes|
59
+ expect { described_class.new(attributes) }.to raise_error ArgumentError, /attributes must be supplied as a Hash/
14
60
  end
61
+ end
62
+
63
+ it { is_expected.to respond_to(:get).with(0).arguments }
15
64
 
16
- ['token', 'receipt'].each do |param|
17
- context "when #{param} is not supplied" do
18
- before { params.delete param }
65
+ describe '#get' do
66
+ before { allow(Warning).to receive(:warn) }
19
67
 
20
- it { expect { described_class.new(params).get }.to raise_error RuntimeError, /#{param} must be supplied/ }
68
+ context 'with a valid receipt' do
69
+ let(:request) { {} }
70
+ let(:response) { legacy_receipt.get }
71
+
72
+ before do
73
+ captured_request = request
74
+ Excon.stub(
75
+ method: :get,
76
+ path: "/1/receipts/#{receipt}.json",
77
+ query: { token: token },
78
+ body: nil
79
+ ) do |request_params|
80
+ captured_request.replace(request_params)
81
+ {
82
+ body: Oj.dump('status' => 1, 'request' => 'request-id', 'acknowledged' => 0),
83
+ headers: { 'X-Limit-App-Remaining' => '9' },
84
+ status: 200
85
+ }
86
+ end
87
+ end
88
+
89
+ it 'delegates to the shared receipt endpoint' do
90
+ response
91
+
92
+ expect(request.slice(:method, :path, :query)).to eq(
93
+ method: :get, path: "/1/receipts/#{receipt}.json", query: "token=#{token}"
94
+ )
95
+ end
96
+
97
+ it 'does not send a request body' do
98
+ response
99
+
100
+ expect(request).not_to have_key(:body)
21
101
  end
102
+
103
+ it 'returns the shared response' do
104
+ expect(response).to have_attributes(status: 1, request: 'request-id', attributes: { 'acknowledged' => 0 })
105
+ end
106
+
107
+ it 'emits the exact migration warning once' do
108
+ response
109
+
110
+ expect(Warning).to have_received(:warn).with(
111
+ a_string_including('Pushover::Receipt#get is deprecated; use Pushover::Client.new(token: ...).receipts.get(receipt: ...)'),
112
+ category: nil
113
+ ).once
114
+ end
115
+ end
116
+
117
+ %i[receipt token].each do |parameter|
118
+ it "preserves the legacy missing-#{parameter} error" do
119
+ instance = described_class.new(receipt: receipt, token: token)
120
+ instance.public_send("#{parameter}=", nil)
121
+
122
+ expect { instance.get }.to raise_error RuntimeError, "#{parameter} must be supplied"
123
+ end
124
+ end
125
+
126
+ it 'applies current receipt validation after the legacy presence checks' do
127
+ instance = described_class.new(receipt: 'receipt', token: token)
128
+
129
+ expect { instance.get }.to raise_error ArgumentError, /receipt must be 30 alphanumeric characters/
22
130
  end
23
131
  end
24
132
  end
@@ -0,0 +1,232 @@
1
+ require 'spec_helper'
2
+
3
+ describe Pushover::Receipts do
4
+ subject(:receipts) { Pushover::Client.new(token: 'app-token').receipts }
5
+
6
+ let(:receipt) { 'a' * 30 }
7
+ let(:receipt_attributes) do
8
+ {
9
+ 'acknowledged' => 1,
10
+ 'acknowledged_at' => 1_723_990_000,
11
+ 'acknowledged_by' => 'user-key',
12
+ 'acknowledged_by_device' => 'phone',
13
+ 'last_delivered_at' => 1_723_989_900,
14
+ 'expired' => 0,
15
+ 'expires_at' => 1_723_993_600,
16
+ 'called_back' => 1,
17
+ 'called_back_at' => 1_723_990_001
18
+ }
19
+ end
20
+ let(:success_body) { { 'status' => 1, 'request' => 'request-id' }.merge(receipt_attributes) }
21
+
22
+ after { Excon.stubs.clear }
23
+
24
+ describe '#get' do
25
+ context 'with a valid receipt' do
26
+ let(:request) { {} }
27
+ let(:response) { receipts.get(receipt: receipt) }
28
+
29
+ before do
30
+ captured_request = request
31
+ Excon.stub(
32
+ method: :get,
33
+ path: "/1/receipts/#{receipt}.json",
34
+ query: { token: 'app-token' },
35
+ body: nil
36
+ ) do |params|
37
+ captured_request.replace(params)
38
+ {
39
+ body: Oj.dump(success_body),
40
+ headers: { 'X-Limit-App-Remaining' => '9' },
41
+ status: 200
42
+ }
43
+ end
44
+ end
45
+
46
+ it 'gets the receipt with the application token in the query string' do
47
+ response
48
+
49
+ expect(request.slice(:method, :path, :query)).to eq(method: :get, path: "/1/receipts/#{receipt}.json", query: 'token=app-token')
50
+ end
51
+
52
+ it 'does not send a request body' do
53
+ response
54
+
55
+ expect(request).not_to have_key(:body)
56
+ end
57
+
58
+ it 'returns the response metadata' do
59
+ expect(response).to have_attributes(status: 1, request: 'request-id')
60
+ end
61
+
62
+ it 'returns the receipt status fields' do
63
+ expect(response.attributes).to eq(receipt_attributes)
64
+ end
65
+
66
+ it 'preserves response headers' do
67
+ expect(response.headers).to include('X-Limit-App-Remaining' => '9')
68
+ end
69
+ end
70
+
71
+ context 'when Pushover rejects the request' do
72
+ before do
73
+ Excon.stub(
74
+ { method: :get, path: "/1/receipts/#{receipt}.json", query: { token: 'app-token' }, body: nil },
75
+ { body: Oj.dump('status' => 0, 'errors' => ['receipt is invalid']), status: 400 }
76
+ )
77
+ end
78
+
79
+ it 'returns errors from a 4xx response' do
80
+ response = receipts.get(receipt: receipt)
81
+
82
+ expect(response).to have_attributes(status: 0, errors: ['receipt is invalid'])
83
+ end
84
+ end
85
+
86
+ it 'requires a receipt' do
87
+ expect { receipts.get(receipt: '') }.to raise_error ArgumentError, /receipt must be supplied/
88
+ end
89
+
90
+ ['a' * 29, 'a' * 31, "#{'a' * 29}-"].each do |invalid_receipt|
91
+ it 'requires exactly 30 alphanumeric characters' do
92
+ expect { receipts.get(receipt: invalid_receipt) }
93
+ .to raise_error ArgumentError, /receipt must be 30 alphanumeric characters/
94
+ end
95
+ end
96
+ end
97
+
98
+ describe '#cancel' do
99
+ context 'with a valid receipt' do
100
+ let(:request) { {} }
101
+ let(:response) { receipts.cancel(receipt: receipt) }
102
+
103
+ before do
104
+ captured_request = request
105
+ Excon.stub(method: :post, path: "/1/receipts/#{receipt}/cancel.json") do |params|
106
+ captured_request.replace(params)
107
+ {
108
+ body: Oj.dump(status: 1, request: 'request-id'),
109
+ headers: { 'X-Limit-App-Remaining' => '9' },
110
+ status: 200
111
+ }
112
+ end
113
+ end
114
+
115
+ it 'posts to the receipt cancellation endpoint' do
116
+ response
117
+
118
+ expect(request.slice(:method, :path)).to eq(method: :post, path: "/1/receipts/#{receipt}/cancel.json")
119
+ end
120
+
121
+ it 'sends the application token as JSON' do
122
+ response
123
+
124
+ expect(Oj.strict_load(request[:body])).to eq('token' => 'app-token')
125
+ end
126
+
127
+ it 'uses the JSON content type' do
128
+ response
129
+
130
+ expect(request[:headers]).to include('Content-Type' => 'application/json')
131
+ end
132
+
133
+ it 'returns the response metadata' do
134
+ expect(response).to have_attributes(status: 1, request: 'request-id')
135
+ end
136
+
137
+ it 'preserves response headers' do
138
+ expect(response.headers).to include('X-Limit-App-Remaining' => '9')
139
+ end
140
+ end
141
+
142
+ context 'when Pushover rejects the request' do
143
+ before do
144
+ Excon.stub(method: :post, path: "/1/receipts/#{receipt}/cancel.json") do |_params|
145
+ { body: Oj.dump(status: 0, errors: ['receipt is invalid']), status: 400 }
146
+ end
147
+ end
148
+
149
+ it 'returns errors from a 4xx response' do
150
+ response = receipts.cancel(receipt: receipt)
151
+
152
+ expect(response).to have_attributes(status: 0, errors: ['receipt is invalid'])
153
+ end
154
+ end
155
+
156
+ it 'requires a receipt' do
157
+ expect { receipts.cancel(receipt: '') }.to raise_error ArgumentError, /receipt must be supplied/
158
+ end
159
+
160
+ ['a' * 29, 'a' * 31, "#{'a' * 29}-"].each do |invalid_receipt|
161
+ it 'requires exactly 30 alphanumeric characters' do
162
+ expect { receipts.cancel(receipt: invalid_receipt) }
163
+ .to raise_error ArgumentError, /receipt must be 30 alphanumeric characters/
164
+ end
165
+ end
166
+ end
167
+
168
+ describe '#cancel_by_tag' do
169
+ context 'with a valid tag' do
170
+ let(:request) { {} }
171
+ let(:response) { receipts.cancel_by_tag(tag: 'l=chicago/ops room') }
172
+
173
+ before do
174
+ captured_request = request
175
+ Excon.stub(method: :post, path: '/1/receipts/cancel_by_tag/l%3Dchicago%2Fops%20room.json') do |params|
176
+ captured_request.replace(params)
177
+ {
178
+ body: Oj.dump(status: 1, request: 'request-id'),
179
+ headers: { 'X-Limit-App-Remaining' => '9' },
180
+ status: 200
181
+ }
182
+ end
183
+ end
184
+
185
+ it 'posts with the tag encoded as one path segment' do
186
+ response
187
+
188
+ expect(request.slice(:method, :path)).to eq(
189
+ method: :post, path: '/1/receipts/cancel_by_tag/l%3Dchicago%2Fops%20room.json'
190
+ )
191
+ end
192
+
193
+ it 'sends the application token as JSON' do
194
+ response
195
+
196
+ expect(Oj.strict_load(request[:body])).to eq('token' => 'app-token')
197
+ end
198
+
199
+ it 'uses the JSON content type' do
200
+ response
201
+
202
+ expect(request[:headers]).to include('Content-Type' => 'application/json')
203
+ end
204
+
205
+ it 'returns the response metadata' do
206
+ expect(response).to have_attributes(status: 1, request: 'request-id')
207
+ end
208
+
209
+ it 'preserves response headers' do
210
+ expect(response.headers).to include('X-Limit-App-Remaining' => '9')
211
+ end
212
+ end
213
+
214
+ context 'when Pushover rejects the request' do
215
+ before do
216
+ Excon.stub(method: :post, path: '/1/receipts/cancel_by_tag/l%3Dchicago%2Fops%20room.json') do |_params|
217
+ { body: Oj.dump(status: 0, errors: ['tag is invalid']), status: 400 }
218
+ end
219
+ end
220
+
221
+ it 'returns errors from a 4xx response' do
222
+ response = receipts.cancel_by_tag(tag: 'l=chicago/ops room')
223
+
224
+ expect(response).to have_attributes(status: 0, errors: ['tag is invalid'])
225
+ end
226
+ end
227
+
228
+ it 'requires a tag' do
229
+ expect { receipts.cancel_by_tag(tag: '') }.to raise_error ArgumentError, /tag must be supplied/
230
+ end
231
+ end
232
+ end
@@ -1,26 +1,31 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe Pushover do
4
- describe Pushover::Response do
5
- let(:excon_response) { Excon::Response.new body: '{}', headers: {} }
3
+ describe Pushover::Response do
4
+ let(:excon_response) { Excon::Response.new body: '{}', headers: {} }
6
5
 
7
- it { is_expected.to have_attributes(status: a_kind_of(String).or(be_nil)) }
8
- it { is_expected.to have_attributes(request: a_kind_of(String).or(be_nil)) }
9
- it { is_expected.to have_attributes(errors: a_kind_of(Array).or(be_nil)) }
10
- it { is_expected.to have_attributes(receipt: a_kind_of(String).or(be_nil)) }
11
- it { is_expected.to have_attributes(headers: a_kind_of(Hash).or(be_nil)) }
12
- it { is_expected.to have_attributes(attributes: a_kind_of(Hash).or(be_nil)) }
6
+ it { is_expected.to have_attributes(status: a_kind_of(String).or(be_nil)) }
7
+ it { is_expected.to have_attributes(request: a_kind_of(String).or(be_nil)) }
8
+ it { is_expected.to have_attributes(errors: a_kind_of(Array).or(a_kind_of(Hash)).or(be_nil)) }
9
+ it { is_expected.to have_attributes(receipt: a_kind_of(String).or(be_nil)) }
10
+ it { is_expected.to have_attributes(headers: a_kind_of(Hash).or(be_nil)) }
11
+ it { is_expected.to have_attributes(attributes: a_kind_of(Hash).or(be_nil)) }
13
12
 
14
- it { expect(described_class).to respond_to(:create_from_excon_response).with(1).argument }
13
+ it { expect(described_class).to respond_to(:create_from_excon_response).with(1).argument }
15
14
 
16
- describe "#to_s" do
17
- it "is expected to be purty." do
18
- expect(Pushover::Response.create_from_excon_response(excon_response).to_s).to include('status')
19
- end
15
+ describe "#to_s" do
16
+ it "is expected to be purty." do
17
+ expect(described_class.create_from_excon_response(excon_response).to_s).to include('status')
20
18
  end
21
19
 
22
- describe "::create_from_excon_response" do
23
- it { expect(Pushover::Response.create_from_excon_response(excon_response)).to be_a_kind_of described_class }
20
+ it 'formats structured errors' do
21
+ excon_response.body = Oj.dump('status' => 0, 'errors' => { 'token' => ['has no license credits'] })
22
+
23
+ expect(described_class.create_from_excon_response(excon_response).to_s)
24
+ .to include('errors: {"token"=>["has no license credits"]}')
24
25
  end
25
26
  end
27
+
28
+ describe "::create_from_excon_response" do
29
+ it { expect(described_class.create_from_excon_response(excon_response)).to be_a described_class }
30
+ end
26
31
  end