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,92 @@
1
+ require 'spec_helper'
2
+
3
+ describe Pushover::Sounds do
4
+ subject(:sounds) { Pushover::Client.new(token: 'app-token').sounds }
5
+
6
+ let(:available_sounds) do
7
+ {
8
+ 'pushover' => 'Pushover (default)',
9
+ 'bike' => 'Bike',
10
+ 'custom-alert' => 'Custom Alert'
11
+ }
12
+ end
13
+
14
+ after { Excon.stubs.clear }
15
+
16
+ describe '#get' do
17
+ context 'when Pushover returns available sounds' do
18
+ let(:request) { {} }
19
+ let(:response) { sounds.get }
20
+
21
+ before do
22
+ captured_request = request
23
+ Excon.stub(
24
+ method: :get,
25
+ path: '/1/sounds.json',
26
+ query: { token: 'app-token' },
27
+ body: nil
28
+ ) do |params|
29
+ captured_request.replace(params)
30
+ {
31
+ body: Oj.dump('status' => 1, 'request' => 'request-id', 'sounds' => available_sounds),
32
+ headers: { 'X-Limit-App-Remaining' => '9' },
33
+ status: 200
34
+ }
35
+ end
36
+ end
37
+
38
+ it 'gets sounds with the application token in the query string' do
39
+ response
40
+
41
+ expect(request.slice(:method, :path, :query)).to eq(
42
+ method: :get, path: '/1/sounds.json', query: 'token=app-token'
43
+ )
44
+ end
45
+
46
+ it 'does not send a request body' do
47
+ response
48
+
49
+ expect(request).not_to have_key(:body)
50
+ end
51
+
52
+ it 'returns response metadata' do
53
+ expect(response).to have_attributes(status: 1, request: 'request-id')
54
+ end
55
+
56
+ it 'returns built-in and custom sounds' do
57
+ expect(response.attributes).to eq('sounds' => available_sounds)
58
+ end
59
+
60
+ it 'preserves response headers' do
61
+ expect(response.headers).to include('X-Limit-App-Remaining' => '9')
62
+ end
63
+ end
64
+
65
+ context 'when Pushover rejects the application token' do
66
+ before do
67
+ Excon.stub(
68
+ { method: :get, path: '/1/sounds.json', query: { token: 'app-token' }, body: nil },
69
+ {
70
+ body: Oj.dump(
71
+ 'status' => 0,
72
+ 'request' => 'request-id',
73
+ 'errors' => ['application token is invalid'],
74
+ 'token' => 'invalid'
75
+ ),
76
+ status: 400
77
+ }
78
+ )
79
+ end
80
+
81
+ it 'returns errors from a 4xx response' do
82
+ response = sounds.get
83
+
84
+ expect(response).to have_attributes(status: 0, errors: ['application token is invalid'])
85
+ end
86
+
87
+ it 'preserves endpoint-specific error details' do
88
+ expect(sounds.get.attributes).to eq('token' => 'invalid')
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,185 @@
1
+ require 'spec_helper'
2
+
3
+ describe Pushover::Subscriptions do
4
+ subject(:subscriptions) { Pushover::Client.new(token: 'app-token').subscriptions }
5
+
6
+ let(:subscription) { 'Forum-f504h08fhlasdfj' }
7
+ let(:user) { 'u' * 30 }
8
+
9
+ after { Excon.stubs.clear }
10
+
11
+ it 'does not expose import as a public alias' do
12
+ expect(subscriptions).not_to respond_to(:import)
13
+ end
14
+
15
+ describe '#migrate' do
16
+ context 'with all documented fields' do
17
+ let(:request) { {} }
18
+ let(:response) do
19
+ subscriptions.migrate(
20
+ subscription: subscription,
21
+ user: user,
22
+ device_name: 'phone-1',
23
+ sound: 'custom-sound'
24
+ )
25
+ end
26
+ let(:expected_body) do
27
+ {
28
+ 'token' => 'app-token',
29
+ 'subscription' => subscription,
30
+ 'user' => user,
31
+ 'device_name' => 'phone-1',
32
+ 'sound' => 'custom-sound'
33
+ }
34
+ end
35
+
36
+ before do
37
+ captured_request = request
38
+ Excon.stub(method: :post, path: '/1/subscriptions/migrate.json') do |params|
39
+ captured_request.replace(params)
40
+ {
41
+ body: Oj.dump(
42
+ 'status' => 1,
43
+ 'request' => 'request-id',
44
+ 'subscribed_user_key' => 's' * 30
45
+ ),
46
+ headers: { 'X-Request-Id' => 'header-id' },
47
+ status: 200
48
+ }
49
+ end
50
+ end
51
+
52
+ it 'posts to the subscription migration endpoint' do
53
+ response
54
+
55
+ expect(request.slice(:method, :path)).to eq(
56
+ method: :post, path: '/1/subscriptions/migrate.json'
57
+ )
58
+ end
59
+
60
+ it 'serializes the application token and migration fields with string keys' do
61
+ response
62
+
63
+ expect(Oj.strict_load(request[:body])).to eq(expected_body)
64
+ end
65
+
66
+ it 'uses the JSON content type' do
67
+ response
68
+
69
+ expect(request[:headers]).to include('Content-Type' => 'application/json')
70
+ end
71
+
72
+ it 'returns response metadata and the subscribed user key' do
73
+ expect(response).to have_attributes(
74
+ status: 1,
75
+ request: 'request-id',
76
+ attributes: { 'subscribed_user_key' => 's' * 30 }
77
+ )
78
+ end
79
+
80
+ it 'preserves response headers' do
81
+ expect(response.headers).to include('X-Request-Id' => 'header-id')
82
+ end
83
+ end
84
+
85
+ context 'without optional fields' do
86
+ let(:request) { {} }
87
+
88
+ before do
89
+ captured_request = request
90
+ Excon.stub(method: :post, path: '/1/subscriptions/migrate.json') do |params|
91
+ captured_request.replace(params)
92
+ { body: Oj.dump('status' => 1, 'request' => 'request-id'), status: 200 }
93
+ end
94
+
95
+ subscriptions.migrate(subscription: subscription, user: user)
96
+ end
97
+
98
+ it 'omits device_name and sound from the JSON body' do
99
+ expect(Oj.strict_load(request[:body])).to eq(
100
+ 'token' => 'app-token', 'subscription' => subscription, 'user' => user
101
+ )
102
+ end
103
+ end
104
+
105
+ context 'with a blank sound' do
106
+ let(:request) { {} }
107
+
108
+ before do
109
+ captured_request = request
110
+ Excon.stub(method: :post, path: '/1/subscriptions/migrate.json') do |params|
111
+ captured_request.replace(params)
112
+ { body: Oj.dump('status' => 1, 'request' => 'request-id'), status: 200 }
113
+ end
114
+
115
+ subscriptions.migrate(subscription: subscription, user: user, sound: '')
116
+ end
117
+
118
+ it 'preserves the documented default-sound selection' do
119
+ expect(Oj.strict_load(request[:body])).to include('sound' => '')
120
+ end
121
+ end
122
+
123
+ context 'when Pushover rejects the migration' do
124
+ before do
125
+ Excon.stub(
126
+ { method: :post, path: '/1/subscriptions/migrate.json' },
127
+ {
128
+ body: Oj.dump(
129
+ 'status' => 0,
130
+ 'request' => 'request-id',
131
+ 'errors' => ['subscription is invalid'],
132
+ 'user' => 'invalid'
133
+ ),
134
+ status: 400
135
+ }
136
+ )
137
+ end
138
+
139
+ it 'returns API errors and preserves endpoint-specific details' do
140
+ expect(subscriptions.migrate(subscription: subscription, user: user)).to have_attributes(
141
+ status: 0, request: 'request-id', errors: ['subscription is invalid'], attributes: { 'user' => 'invalid' }
142
+ )
143
+ end
144
+ end
145
+
146
+ [nil, ''].each do |invalid_subscription|
147
+ it 'requires a subscription code' do
148
+ expect { subscriptions.migrate(subscription: invalid_subscription, user: user) }
149
+ .to raise_error ArgumentError, /subscription must be supplied/
150
+ end
151
+ end
152
+
153
+ it 'requires the subscription code to be a string' do
154
+ expect { subscriptions.migrate(subscription: 123, user: user) }
155
+ .to raise_error ArgumentError, /subscription must be a String/
156
+ end
157
+
158
+ [nil, ''].each do |invalid_user|
159
+ it 'requires a user key' do
160
+ expect { subscriptions.migrate(subscription: subscription, user: invalid_user) }
161
+ .to raise_error ArgumentError, /user must be supplied/
162
+ end
163
+ end
164
+
165
+ ['u' * 29, 'u' * 31, "#{'u' * 29}-", 123].each do |invalid_user|
166
+ it 'requires a 30-character alphanumeric user key' do
167
+ expect { subscriptions.migrate(subscription: subscription, user: invalid_user) }
168
+ .to raise_error ArgumentError, /user must be 30 alphanumeric characters/
169
+ end
170
+ end
171
+
172
+ ['', 'd' * 26, 'invalid device', 'phone,watch', 123].each do |invalid_device|
173
+ it 'validates an optional device name' do
174
+ expect do
175
+ subscriptions.migrate(subscription: subscription, user: user, device_name: invalid_device)
176
+ end.to raise_error ArgumentError, /device_name must be 1 to 25/
177
+ end
178
+ end
179
+
180
+ it 'requires an optional sound to be a string' do
181
+ expect { subscriptions.migrate(subscription: subscription, user: user, sound: 123) }
182
+ .to raise_error ArgumentError, /sound must be a String/
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,325 @@
1
+ require 'spec_helper'
2
+
3
+ describe Pushover::Teams do
4
+ subject(:teams) { Pushover::Client.new(token: 'team-token').teams }
5
+
6
+ after { Excon.stubs.clear }
7
+
8
+ describe '#get' do
9
+ context 'when Pushover returns team information' do
10
+ let(:request) { {} }
11
+ let(:response) { teams.get }
12
+ let(:team_attributes) do
13
+ {
14
+ 'name' => 'Operations',
15
+ 'users' => [
16
+ {
17
+ 'id' => 'member-id',
18
+ 'name' => 'Team Member',
19
+ 'email' => 'member@example.test',
20
+ 'administrator' => true,
21
+ 'devices' => [
22
+ {
23
+ 'name' => 'iphone',
24
+ 'os' => 'iOS',
25
+ 'os_version' => '26',
26
+ 'enabled' => true,
27
+ 'administratively_disabled' => false
28
+ }
29
+ ]
30
+ }
31
+ ]
32
+ }
33
+ end
34
+
35
+ before do
36
+ captured_request = request
37
+ Excon.stub(
38
+ method: :get,
39
+ path: '/1/teams.json',
40
+ query: { token: 'team-token' },
41
+ body: nil
42
+ ) do |params|
43
+ captured_request.replace(params)
44
+ {
45
+ body: Oj.dump({ 'status' => 1, 'request' => 'request-id' }.merge(team_attributes)),
46
+ headers: { 'X-Request-Id' => 'header-id' },
47
+ status: 200
48
+ }
49
+ end
50
+ end
51
+
52
+ it 'gets the team with the team token in the query string' do
53
+ response
54
+
55
+ expect(request.slice(:method, :path, :query)).to eq(
56
+ method: :get, path: '/1/teams.json', query: 'token=team-token'
57
+ )
58
+ end
59
+
60
+ it 'does not send a request body' do
61
+ response
62
+
63
+ expect(request).not_to have_key(:body)
64
+ end
65
+
66
+ it 'preserves the team, users, and nested devices' do
67
+ expect(response).to have_attributes(
68
+ status: 1, request: 'request-id', headers: include('X-Request-Id' => 'header-id'), attributes: team_attributes
69
+ )
70
+ end
71
+ end
72
+
73
+ context 'when Pushover rejects the team token' do
74
+ before do
75
+ Excon.stub(
76
+ { method: :get, path: '/1/teams.json', query: { token: 'team-token' }, body: nil },
77
+ {
78
+ body: Oj.dump('status' => 0, 'request' => 'request-id', 'errors' => ['team token is invalid']),
79
+ status: 400
80
+ }
81
+ )
82
+ end
83
+
84
+ it 'returns errors from the 400 response' do
85
+ expect(teams.get).to have_attributes(
86
+ status: 0, request: 'request-id', errors: ['team token is invalid']
87
+ )
88
+ end
89
+ end
90
+ end
91
+
92
+ describe '#add_user' do
93
+ context 'with all documented fields' do
94
+ let(:request) { {} }
95
+ let(:expected_body) do
96
+ {
97
+ 'token' => 'team-token',
98
+ 'email' => 'member@example.test',
99
+ 'name' => 'Team Member',
100
+ 'password' => 'temporary password',
101
+ 'instant' => 'true',
102
+ 'admin' => 'true',
103
+ 'group' => 'On-call'
104
+ }
105
+ end
106
+ let(:response) do
107
+ teams.add_user(
108
+ email: 'member@example.test',
109
+ name: 'Team Member',
110
+ password: 'temporary password',
111
+ instant: true,
112
+ admin: true,
113
+ group: 'On-call'
114
+ )
115
+ end
116
+
117
+ before do
118
+ captured_request = request
119
+ Excon.stub(method: :post, path: '/1/teams/add_user.json') do |params|
120
+ captured_request.replace(params)
121
+ {
122
+ body: Oj.dump('status' => 1, 'request' => 'request-id'),
123
+ headers: { 'X-Request-Id' => 'header-id' },
124
+ status: 200
125
+ }
126
+ end
127
+ end
128
+
129
+ it 'posts to the add-user endpoint' do
130
+ response
131
+
132
+ expect(request.slice(:method, :path)).to eq(
133
+ method: :post, path: '/1/teams/add_user.json'
134
+ )
135
+ end
136
+
137
+ it 'serializes string-key JSON and literal true flag values' do
138
+ response
139
+
140
+ expect(Oj.strict_load(request[:body])).to eq(expected_body)
141
+ end
142
+
143
+ it 'uses the JSON content type' do
144
+ response
145
+
146
+ expect(request[:headers]).to include('Content-Type' => 'application/json')
147
+ end
148
+
149
+ it 'returns response metadata and headers' do
150
+ expect(response).to have_attributes(
151
+ status: 1, request: 'request-id', attributes: {}, headers: include('X-Request-Id' => 'header-id')
152
+ )
153
+ end
154
+ end
155
+
156
+ context 'with only an e-mail address' do
157
+ let(:request) { {} }
158
+
159
+ before do
160
+ captured_request = request
161
+ Excon.stub(method: :post, path: '/1/teams/add_user.json') do |params|
162
+ captured_request.replace(params)
163
+ { body: Oj.dump('status' => 1, 'request' => 'request-id'), status: 200 }
164
+ end
165
+
166
+ teams.add_user(email: 'member@example.test')
167
+ end
168
+
169
+ it 'omits optional fields' do
170
+ expect(Oj.strict_load(request[:body])).to eq(
171
+ 'token' => 'team-token', 'email' => 'member@example.test'
172
+ )
173
+ end
174
+ end
175
+
176
+ context 'with blank text fields and disabled flags' do
177
+ let(:request) { {} }
178
+ let(:expected_body) do
179
+ {
180
+ 'token' => 'team-token',
181
+ 'email' => 'member@example.test',
182
+ 'name' => '',
183
+ 'password' => '',
184
+ 'group' => ''
185
+ }
186
+ end
187
+
188
+ before do
189
+ captured_request = request
190
+ Excon.stub(method: :post, path: '/1/teams/add_user.json') do |params|
191
+ captured_request.replace(params)
192
+ { body: Oj.dump('status' => 1, 'request' => 'request-id'), status: 200 }
193
+ end
194
+
195
+ teams.add_user(
196
+ email: 'member@example.test', name: '', password: '', instant: false, admin: false, group: ''
197
+ )
198
+ end
199
+
200
+ it 'preserves blank strings and omits false flags' do
201
+ expect(Oj.strict_load(request[:body])).to eq(expected_body)
202
+ end
203
+ end
204
+
205
+ context 'when Pushover rejects the request' do
206
+ before do
207
+ Excon.stub(
208
+ { method: :post, path: '/1/teams/add_user.json' },
209
+ {
210
+ body: Oj.dump('status' => 0, 'request' => 'request-id', 'errors' => ['user could not be added']),
211
+ status: 400
212
+ }
213
+ )
214
+ end
215
+
216
+ it 'returns errors from the 400 response' do
217
+ expect(teams.add_user(email: 'member@example.test')).to have_attributes(
218
+ status: 0, request: 'request-id', errors: ['user could not be added']
219
+ )
220
+ end
221
+ end
222
+
223
+ [nil, '', ' ', 123].each do |invalid_email|
224
+ it 'requires a nonblank e-mail string' do
225
+ expect { teams.add_user(email: invalid_email) }
226
+ .to raise_error ArgumentError, /email must be a nonblank String/
227
+ end
228
+ end
229
+
230
+ %i[name password group].each do |field|
231
+ it "requires #{field} to be a string when supplied" do
232
+ expect { teams.add_user(email: 'member@example.test', field => 123) }
233
+ .to raise_error ArgumentError, /#{field} must be a String/
234
+ end
235
+ end
236
+
237
+ %i[instant admin].each do |field|
238
+ it "requires #{field} to be boolean when supplied" do
239
+ expect { teams.add_user(email: 'member@example.test', field => 'true') }
240
+ .to raise_error ArgumentError, /#{field} must be true or false/
241
+ end
242
+ end
243
+
244
+ it 'rejects unsupported fields' do
245
+ expect { teams.add_user(email: 'member@example.test', role: 'owner') }
246
+ .to raise_error ArgumentError, /unsupported team user parameter: role/
247
+ end
248
+ end
249
+
250
+ shared_examples 'a team e-mail mutation' do |operation, path|
251
+ let(:request) { {} }
252
+ let(:response) { teams.public_send(operation, email: 'member@example.test') }
253
+ let(:api_response) do
254
+ {
255
+ body: Oj.dump('status' => 1, 'request' => 'request-id'),
256
+ headers: { 'X-Request-Id' => 'header-id' },
257
+ status: 200
258
+ }
259
+ end
260
+
261
+ before do
262
+ captured_request = request
263
+ Excon.stub(method: :post, path: path) do |params|
264
+ captured_request.replace(params)
265
+ api_response
266
+ end
267
+ end
268
+
269
+ it 'posts to the documented endpoint' do
270
+ response
271
+
272
+ expect(request.slice(:method, :path)).to eq(method: :post, path: path)
273
+ end
274
+
275
+ it 'serializes the team token and e-mail with string keys' do
276
+ response
277
+
278
+ expect(Oj.strict_load(request[:body])).to eq(
279
+ 'token' => 'team-token', 'email' => 'member@example.test'
280
+ )
281
+ end
282
+
283
+ it 'uses the JSON content type' do
284
+ response
285
+
286
+ expect(request[:headers]).to include('Content-Type' => 'application/json')
287
+ end
288
+
289
+ it 'returns response metadata and headers' do
290
+ expect(response).to have_attributes(
291
+ status: 1, request: 'request-id', attributes: {}, headers: include('X-Request-Id' => 'header-id')
292
+ )
293
+ end
294
+
295
+ context 'when Pushover rejects the request' do
296
+ let(:api_response) do
297
+ {
298
+ body: Oj.dump('status' => 0, 'request' => 'request-id', 'errors' => ['team operation failed']),
299
+ status: 400
300
+ }
301
+ end
302
+
303
+ it 'returns errors from the 400 response' do
304
+ expect(response).to have_attributes(
305
+ status: 0, request: 'request-id', errors: ['team operation failed']
306
+ )
307
+ end
308
+ end
309
+
310
+ [nil, '', ' ', 123].each do |invalid_email|
311
+ it 'requires a nonblank e-mail string' do
312
+ expect { teams.public_send(operation, email: invalid_email) }
313
+ .to raise_error ArgumentError, /email must be a nonblank String/
314
+ end
315
+ end
316
+ end
317
+
318
+ describe '#revoke_invitation' do
319
+ it_behaves_like 'a team e-mail mutation', :revoke_invitation, '/1/teams/revoke_invitation.json'
320
+ end
321
+
322
+ describe '#remove_user' do
323
+ it_behaves_like 'a team e-mail mutation', :remove_user, '/1/teams/remove_user.json'
324
+ end
325
+ end