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,238 @@
1
+ require 'spec_helper'
2
+
3
+ describe Pushover::Licenses do
4
+ subject(:licenses) { Pushover::Client.new(token: 'app-token').licenses }
5
+
6
+ let(:user) { 'u' * 30 }
7
+
8
+ after { Excon.stubs.clear }
9
+
10
+ describe '#get' do
11
+ context 'when Pushover returns available license credits' do
12
+ let(:request) { {} }
13
+ let(:response) { licenses.get }
14
+
15
+ before do
16
+ captured_request = request
17
+ Excon.stub(
18
+ method: :get,
19
+ path: '/1/licenses.json',
20
+ query: { token: 'app-token' },
21
+ body: nil
22
+ ) do |params|
23
+ captured_request.replace(params)
24
+ {
25
+ body: Oj.dump('status' => 1, 'request' => 'request-id', 'credits' => 5),
26
+ headers: { 'X-Request-Id' => 'header-id' },
27
+ status: 200
28
+ }
29
+ end
30
+ end
31
+
32
+ it 'gets credits with the application token in the query string' do
33
+ response
34
+
35
+ expect(request.slice(:method, :path, :query)).to eq(
36
+ method: :get, path: '/1/licenses.json', query: 'token=app-token'
37
+ )
38
+ end
39
+
40
+ it 'does not send a request body' do
41
+ response
42
+
43
+ expect(request).not_to have_key(:body)
44
+ end
45
+
46
+ it 'returns response metadata and available credits' do
47
+ expect(response).to have_attributes(
48
+ status: 1, request: 'request-id', attributes: { 'credits' => 5 }
49
+ )
50
+ end
51
+
52
+ it 'preserves response headers' do
53
+ expect(response.headers).to include('X-Request-Id' => 'header-id')
54
+ end
55
+ end
56
+
57
+ context 'when Pushover rejects the application token' do
58
+ before do
59
+ Excon.stub(
60
+ { method: :get, path: '/1/licenses.json', query: { token: 'app-token' }, body: nil },
61
+ {
62
+ body: Oj.dump(
63
+ 'status' => 0,
64
+ 'request' => 'request-id',
65
+ 'errors' => { 'token' => ['is invalid'] }
66
+ ),
67
+ status: 400
68
+ }
69
+ )
70
+ end
71
+
72
+ it 'preserves structured licensing errors' do
73
+ expect(licenses.get).to have_attributes(
74
+ status: 0, request: 'request-id', errors: { 'token' => ['is invalid'] }
75
+ )
76
+ end
77
+ end
78
+ end
79
+
80
+ describe '#assign' do
81
+ context 'with a user key and operating system' do
82
+ let(:request) { {} }
83
+ let(:response) { licenses.assign(user: user, os: 'Android') }
84
+
85
+ before do
86
+ captured_request = request
87
+ Excon.stub(method: :post, path: '/1/licenses/assign.json') do |params|
88
+ captured_request.replace(params)
89
+ {
90
+ body: Oj.dump('status' => 1, 'request' => 'request-id', 'credits' => 4),
91
+ headers: { 'X-Request-Id' => 'header-id' },
92
+ status: 200
93
+ }
94
+ end
95
+ end
96
+
97
+ it 'posts one non-idempotent request to the assignment endpoint' do
98
+ response
99
+
100
+ expect(request.slice(:method, :path, :idempotent)).to eq(
101
+ method: :post, path: '/1/licenses/assign.json', idempotent: false
102
+ )
103
+ end
104
+
105
+ it 'serializes the application token and assignment fields with string keys' do
106
+ response
107
+
108
+ expect(Oj.strict_load(request[:body])).to eq(
109
+ 'token' => 'app-token', 'user' => user, 'os' => 'Android'
110
+ )
111
+ end
112
+
113
+ it 'uses the JSON content type' do
114
+ response
115
+
116
+ expect(request[:headers]).to include('Content-Type' => 'application/json')
117
+ end
118
+
119
+ it 'returns response metadata, remaining credits, and headers' do
120
+ expect(response).to have_attributes(
121
+ status: 1, request: 'request-id', attributes: { 'credits' => 4 }, headers: include('X-Request-Id' => 'header-id')
122
+ )
123
+ end
124
+ end
125
+
126
+ context 'with an email address' do
127
+ let(:request) { {} }
128
+
129
+ before do
130
+ captured_request = request
131
+ Excon.stub(method: :post, path: '/1/licenses/assign.json') do |params|
132
+ captured_request.replace(params)
133
+ { body: Oj.dump('status' => 1, 'request' => 'request-id', 'credits' => 4), status: 200 }
134
+ end
135
+
136
+ licenses.assign(email: 'person@example.test')
137
+ end
138
+
139
+ it 'omits the user and operating system fields' do
140
+ expect(Oj.strict_load(request[:body])).to eq(
141
+ 'token' => 'app-token', 'email' => 'person@example.test'
142
+ )
143
+ end
144
+ end
145
+
146
+ context 'with both recipient identifiers' do
147
+ let(:request) { {} }
148
+
149
+ before do
150
+ captured_request = request
151
+ Excon.stub(method: :post, path: '/1/licenses/assign.json') do |params|
152
+ captured_request.replace(params)
153
+ { body: Oj.dump('status' => 1, 'request' => 'request-id', 'credits' => 4), status: 200 }
154
+ end
155
+
156
+ licenses.assign(user: user, email: 'person@example.test')
157
+ end
158
+
159
+ it 'does not invent a mutual-exclusion rule' do
160
+ expect(Oj.strict_load(request[:body])).to include(
161
+ 'user' => user, 'email' => 'person@example.test'
162
+ )
163
+ end
164
+ end
165
+
166
+ context 'with a blank operating system' do
167
+ let(:request) { {} }
168
+
169
+ before do
170
+ captured_request = request
171
+ Excon.stub(method: :post, path: '/1/licenses/assign.json') do |params|
172
+ captured_request.replace(params)
173
+ { body: Oj.dump('status' => 1, 'request' => 'request-id', 'credits' => 4), status: 200 }
174
+ end
175
+
176
+ licenses.assign(user: user, os: '')
177
+ end
178
+
179
+ it 'preserves the documented first-platform selection' do
180
+ expect(Oj.strict_load(request[:body])).to include('os' => '')
181
+ end
182
+ end
183
+
184
+ context 'when Pushover rejects the assignment' do
185
+ before do
186
+ Excon.stub(
187
+ { method: :post, path: '/1/licenses/assign.json' },
188
+ {
189
+ body: Oj.dump(
190
+ 'status' => 0,
191
+ 'request' => 'request-id',
192
+ 'errors' => { 'token' => ['is out of available license credits'] }
193
+ ),
194
+ status: 400
195
+ }
196
+ )
197
+ end
198
+
199
+ it 'returns structured errors without retrying or flattening them' do
200
+ expect(licenses.assign(user: user)).to have_attributes(
201
+ status: 0,
202
+ request: 'request-id',
203
+ errors: { 'token' => ['is out of available license credits'] }
204
+ )
205
+ end
206
+ end
207
+
208
+ it 'requires a user key or email address' do
209
+ expect { licenses.assign }.to raise_error ArgumentError, /user or email must be supplied/
210
+ end
211
+
212
+ it 'validates a supplied blank user key' do
213
+ expect { licenses.assign(user: '', email: 'person@example.test') }
214
+ .to raise_error ArgumentError, /user must be supplied/
215
+ end
216
+
217
+ ['u' * 29, 'u' * 31, "#{'u' * 29}-", 123].each do |invalid_user|
218
+ it 'requires a 30-character alphanumeric user key' do
219
+ expect { licenses.assign(user: invalid_user) }
220
+ .to raise_error ArgumentError, /user must be 30 alphanumeric characters/
221
+ end
222
+ end
223
+
224
+ ['', ' ', 123].each do |invalid_email|
225
+ it 'requires a supplied email address to be a nonblank string' do
226
+ expect { licenses.assign(email: invalid_email) }
227
+ .to raise_error ArgumentError, /email must be a nonblank String/
228
+ end
229
+ end
230
+
231
+ ['android', 'IOS', 'Windows', 123].each do |invalid_os|
232
+ it 'accepts only documented operating systems' do
233
+ expect { licenses.assign(user: user, os: invalid_os) }
234
+ .to raise_error ArgumentError, /os must be blank, Android, iOS, or Desktop/
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,92 @@
1
+ require 'spec_helper'
2
+
3
+ describe Pushover::Limits do
4
+ subject(:limits) { Pushover::Client.new(token: 'app-token').limits }
5
+
6
+ let(:usage_limits) do
7
+ {
8
+ 'limit' => 10_000,
9
+ 'remaining' => 7_496,
10
+ 'reset' => 1_393_653_600
11
+ }
12
+ end
13
+
14
+ after { Excon.stubs.clear }
15
+
16
+ describe '#get' do
17
+ context 'when Pushover returns account usage limits' do
18
+ let(:request) { {} }
19
+ let(:response) { limits.get }
20
+
21
+ before do
22
+ captured_request = request
23
+ Excon.stub(
24
+ method: :get,
25
+ path: '/1/apps/limits.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' }.merge(usage_limits)),
32
+ headers: { 'X-Limit-App-Remaining' => '7496' },
33
+ status: 200
34
+ }
35
+ end
36
+ end
37
+
38
+ it 'gets limits 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/apps/limits.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 the account usage limit, remaining messages, and reset time' do
57
+ expect(response.attributes).to eq(usage_limits)
58
+ end
59
+
60
+ it 'preserves response headers' do
61
+ expect(response.headers).to include('X-Limit-App-Remaining' => '7496')
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/apps/limits.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 = limits.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(limits.get.attributes).to eq('token' => 'invalid')
89
+ end
90
+ end
91
+ end
92
+ end
@@ -1,35 +1,155 @@
1
+ require 'open3'
2
+ require 'rbconfig'
1
3
  require 'spec_helper'
2
4
 
3
5
  describe Pushover::Message do
4
- it { is_expected.to have_attributes(token: a_kind_of(String).or(be_nil)) }
5
- it { is_expected.to have_attributes(user: a_kind_of(String).or(be_nil)) }
6
- it { is_expected.to have_attributes(message: a_kind_of(String).or(be_nil)) }
7
- it { is_expected.to have_attributes(attachment: a_kind_of(String).or(be_nil)) }
8
- it { is_expected.to have_attributes(device: a_kind_of(String).or(be_nil)) }
9
- it { is_expected.to have_attributes(title: a_kind_of(String).or(be_nil)) }
10
- it { is_expected.to have_attributes(url: a_kind_of(String).or(be_nil)) }
11
- it { is_expected.to have_attributes(url_title: a_kind_of(String).or(be_nil)) }
12
- it { is_expected.to have_attributes(priority: a_kind_of(Numeric).or(be_nil)) }
13
- it { is_expected.to have_attributes(sound: a_kind_of(String).or(be_nil)) }
14
- it { is_expected.to have_attributes(timestamp: a_kind_of(DateTime).or(be_nil)) }
15
- it { is_expected.to have_attributes(expire: a_kind_of(Numeric).or(be_nil)) }
16
- it { is_expected.to have_attributes(retry: a_kind_of(Numeric).or(be_nil)) }
17
- it { is_expected.to have_attributes(callback: a_kind_of(String).or(be_nil)) }
18
-
19
- it { is_expected.to respond_to(:push).with(0).argument }
20
-
21
- describe "#push" do
22
- let(:params) { { 'token' => 't', 'user' => 'u', 'message' => 'message' } }
23
-
24
- it "is expected to send" do
25
- expect { described_class.new(params).push }.to raise_error Excon::Error::StubNotFound
6
+ subject(:legacy_message) { described_class.new(params) }
7
+
8
+ let(:params) { { token: 'app-token', user: 'user-key', message: 'Hello' } }
9
+ let(:standalone_script) do
10
+ <<~RUBY
11
+ require 'excon'
12
+ Excon.defaults[:mock] = true
13
+ require 'pushover/message'
14
+ Excon.stub(method: :post, path: '/1/messages.json') { { body: Oj.dump('status' => 1), status: 200 } }
15
+ abort unless Pushover::Message.new(token: 'app-token', user: 'user-key', message: 'Hello').push.status == 1
16
+ RUBY
17
+ end
18
+
19
+ after { Excon.stubs.clear }
20
+
21
+ it 'supports the standalone legacy require path' do
22
+ _stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-e', standalone_script)
23
+
24
+ expect(status).to be_success, stderr
25
+ end
26
+
27
+ it 'uses an explicit compatibility class' do
28
+ expect(described_class.superclass).to eq(Object)
29
+ end
30
+
31
+ it 'accepts the legacy keyword form' do
32
+ message = described_class.new(token: 'app-token', user: 'user-key', message: 'Hello')
33
+
34
+ expect(message).to have_attributes(token: 'app-token', user: 'user-key', message: 'Hello')
35
+ end
36
+
37
+ it 'accepts a single Hash with string keys' do
38
+ message = described_class.new('token' => 'app-token', 'user' => 'user-key', 'message' => 'Hello')
39
+
40
+ expect(message).to have_attributes(token: 'app-token', user: 'user-key', message: 'Hello')
41
+ end
42
+
43
+ it 'keeps the legacy members writable' do
44
+ legacy_message.title = 'Updated'
45
+
46
+ expect(legacy_message.title).to eq('Updated')
47
+ end
48
+
49
+ it 'rejects unknown members' do
50
+ expect { described_class.new(unknown: 'value') }.to raise_error ArgumentError
51
+ end
52
+
53
+ it 'rejects non-Hash positional attributes' do
54
+ [nil, false, 1, 'invalid'].each do |attributes|
55
+ expect { described_class.new(attributes) }.to raise_error ArgumentError, /attributes must be supplied as a Hash/
56
+ end
57
+ end
58
+
59
+ it { is_expected.to respond_to(:push).with(0).arguments }
60
+
61
+ describe '#push' do
62
+ before { allow(Warning).to receive(:warn) }
63
+
64
+ context 'with supported legacy fields' do
65
+ let(:request) { {} }
66
+ let(:response) { legacy_message.push }
67
+ let(:params) do
68
+ {
69
+ token: 'app-token',
70
+ user: 'user-key',
71
+ message: 'Hello',
72
+ device: 'phone-1',
73
+ title: 'Greeting',
74
+ url: 'https://example.test/details',
75
+ url_title: 'Details',
76
+ priority: 2,
77
+ sound: 'siren',
78
+ timestamp: 1_723_990_000,
79
+ expire: 60,
80
+ retry: 30,
81
+ callback: 'https://example.test/callback'
82
+ }
83
+ end
84
+
85
+ before do
86
+ captured_request = request
87
+ Excon.stub(method: :post, path: '/1/messages.json') do |request_params|
88
+ captured_request.replace(request_params)
89
+ {
90
+ body: Oj.dump('status' => 1, 'request' => 'request-id'),
91
+ headers: { 'X-Limit-App-Remaining' => '9' },
92
+ status: 200
93
+ }
94
+ end
95
+ end
96
+
97
+ it 'delegates to the shared message endpoint' do
98
+ response
99
+
100
+ expect(request.slice(:method, :path)).to eq(method: :post, path: '/1/messages.json')
101
+ end
102
+
103
+ it 'serializes supported fields through the shared resource' do
104
+ response
105
+ expected = params.except(:token).transform_keys(&:to_s).merge('token' => 'app-token')
106
+
107
+ expect(Oj.strict_load(request[:body])).to eq(expected)
108
+ end
109
+
110
+ it 'uses the shared JSON content type' do
111
+ response
112
+
113
+ expect(request[:headers]).to include('Content-Type' => 'application/json')
114
+ end
115
+
116
+ it 'returns the shared response' do
117
+ expect(response).to have_attributes(
118
+ status: 1, request: 'request-id', headers: include('X-Limit-App-Remaining' => '9')
119
+ )
120
+ end
121
+
122
+ it 'emits the exact migration warning once' do
123
+ response
124
+
125
+ expect(Warning).to have_received(:warn).with(
126
+ a_string_including('Pushover::Message#push is deprecated; use Pushover::Client.new(token: ...).messages.create(...)'),
127
+ category: nil
128
+ ).once
129
+ end
130
+ end
131
+
132
+ it 'returns Pushover errors from the shared resource' do
133
+ Excon.stub(method: :post, path: '/1/messages.json') do |_request_params|
134
+ { body: Oj.dump('status' => 0, 'errors' => ['user identifier is invalid']), status: 400 }
135
+ end
136
+
137
+ expect(legacy_message.push).to have_attributes(status: 0, errors: ['user identifier is invalid'])
138
+ end
139
+
140
+ %i[token user message].each do |parameter|
141
+ it "preserves the legacy missing-#{parameter} error" do
142
+ instance = described_class.new(params.except(parameter))
143
+
144
+ expect { instance.push }.to raise_error RuntimeError, "#{parameter} must be supplied"
145
+ end
26
146
  end
27
147
 
28
- ['token', 'user', 'message'].each do |param|
29
- context "when #{param} is not supplied" do
30
- before { params.delete param }
148
+ ['', 'raw attachment'].each do |attachment|
149
+ it 'rejects a non-nil raw attachment explicitly' do
150
+ instance = described_class.new(params.merge(attachment: attachment))
31
151
 
32
- it { expect { described_class.new(params).push }.to raise_error RuntimeError, /#{param} must be supplied/ }
152
+ expect { instance.push }.to raise_error ArgumentError, /raw attachment is unsupported/
33
153
  end
34
154
  end
35
155
  end