clicksend 1.1.0 → 1.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/README.md +457 -74
- data/docs/clicksend-api-notes.md +49 -5
- data/lib/clicksend/client.rb +16 -0
- data/lib/clicksend/connection.rb +81 -26
- data/lib/clicksend/errors.rb +14 -10
- data/lib/clicksend/instrumentation.rb +6 -0
- data/lib/clicksend/page.rb +6 -1
- data/lib/clicksend/resources/sms.rb +77 -0
- data/lib/clicksend/retry_policy.rb +5 -2
- data/lib/clicksend/testing/failure.rb +15 -4
- data/lib/clicksend/testing/fake_api.rb +87 -18
- data/lib/clicksend/testing/minitest.rb +56 -0
- data/lib/clicksend/testing/payloads.rb +15 -0
- data/lib/clicksend/testing/records.rb +17 -1
- data/lib/clicksend/testing/rspec.rb +124 -0
- data/lib/clicksend/testing/sms_expectations.rb +91 -0
- data/lib/clicksend/testing.rb +6 -0
- data/lib/clicksend/transport.rb +29 -9
- data/lib/clicksend/version.rb +1 -1
- data/lib/clicksend/webhook.rb +22 -16
- metadata +4 -1
|
@@ -10,16 +10,24 @@ module Clicksend
|
|
|
10
10
|
# fake.sent_messages.last.custom_string # => "otp:42"
|
|
11
11
|
#
|
|
12
12
|
# It emulates these endpoints, with ClickSend's response shapes:
|
|
13
|
-
# POST /v3/sms/send, GET /v3/account,
|
|
14
|
-
# receipt, mark read)
|
|
13
|
+
# POST /v3/sms/send, GET /v3/account, receipts and inbound (list, one
|
|
14
|
+
# receipt, mark read), and cancelling a scheduled message. Anything else
|
|
15
|
+
# answers 404 unless #stub-bed.
|
|
15
16
|
#
|
|
16
|
-
# It deliberately does not serve GET /v3/sms/history. ClickSend
|
|
17
|
-
# say how soon a sent message appears there, and an always-up-to-
|
|
18
|
-
# history would let a "not in history, so resend" rule pass its
|
|
19
|
-
# then send twice in production. To test reconciliation code,
|
|
20
|
-
# history
|
|
21
|
-
#
|
|
22
|
-
#
|
|
17
|
+
# It deliberately does not serve GET /v3/sms/history by itself. ClickSend
|
|
18
|
+
# doesn't say how soon a sent message appears there, and an always-up-to-
|
|
19
|
+
# date fake history would let a "not in history, so resend" rule pass its
|
|
20
|
+
# tests and then send twice in production. To test reconciliation code,
|
|
21
|
+
# say what history shows with #stub_history, including nothing.
|
|
22
|
+
#
|
|
23
|
+
# Cancelling (PUT /v3/sms/{message_id}/cancel) succeeds for a message the
|
|
24
|
+
# fake accepted with a schedule still in the future. ClickSend doesn't
|
|
25
|
+
# document its answer for any other message (unknown, already sent, already
|
|
26
|
+
# cancelled), so the fake raises Testing::StubError for those: #stub the
|
|
27
|
+
# answer your test assumes.
|
|
28
|
+
#
|
|
29
|
+
# Recipients can be rejected (#reject), failures injected (#fail_next) and
|
|
30
|
+
# receipts and replies seeded (#add_receipt, #add_inbound).
|
|
23
31
|
#
|
|
24
32
|
# Simplifications, so tests don't come to depend on them:
|
|
25
33
|
# - the balance never changes; +message_parts+ is an estimate (one per
|
|
@@ -44,6 +52,8 @@ module Clicksend
|
|
|
44
52
|
# failures) is not a mistake in the fake's setup and passes through as is.
|
|
45
53
|
MISTAKES = [StandardError, ScriptError].freeze
|
|
46
54
|
STATUS_TEXTS = {200 => "Sent", 201 => "Delivered", 300 => "Retrying", 301 => "Failed"}.freeze
|
|
55
|
+
# The history statuses ClickSend documents for outbound messages.
|
|
56
|
+
HISTORY_STATUSES = %w[Queued Completed Scheduled WaitApproval Failed Cancelled CancelledAfterReview Sent].freeze
|
|
47
57
|
ROUTES = [
|
|
48
58
|
[:post, %r{\A/v3/sms/send\z}, :send_sms],
|
|
49
59
|
[:get, %r{\A/v3/account\z}, :account],
|
|
@@ -52,9 +62,10 @@ module Clicksend
|
|
|
52
62
|
[:put, %r{\A/v3/sms/receipts-read\z}, :mark_receipts_read],
|
|
53
63
|
[:get, %r{\A/v3/sms/inbound\z}, :list_inbound],
|
|
54
64
|
[:put, %r{\A/v3/sms/inbound-read\z}, :mark_inbound_read],
|
|
55
|
-
[:put, %r{\A/v3/sms/inbound-read/([A-Za-z0-9-]+)\z}, :mark_inbound_message_read]
|
|
65
|
+
[:put, %r{\A/v3/sms/inbound-read/([A-Za-z0-9-]+)\z}, :mark_inbound_message_read],
|
|
66
|
+
[:put, %r{\A/v3/sms/([A-Za-z0-9-]+)/cancel\z}, :cancel_sms]
|
|
56
67
|
].freeze
|
|
57
|
-
private_constant :DECIMAL, :RECIPIENT, :MISTAKES, :STATUS_TEXTS, :ROUTES
|
|
68
|
+
private_constant :DECIMAL, :RECIPIENT, :MISTAKES, :STATUS_TEXTS, :HISTORY_STATUSES, :ROUTES
|
|
58
69
|
|
|
59
70
|
# @param balance [String] the account balance, as ClickSend's decimal String
|
|
60
71
|
# @param currency [String] e.g. "AUD"
|
|
@@ -72,6 +83,7 @@ module Clicksend
|
|
|
72
83
|
@clock = clock
|
|
73
84
|
@lock = Mutex.new
|
|
74
85
|
@outbox = [] # [SentMessage, accepted payload]
|
|
86
|
+
@cancelled = {} # message_id => SentMessage
|
|
75
87
|
@requests = []
|
|
76
88
|
@receipts = []
|
|
77
89
|
@latest_receipts = {} # message_id => payload
|
|
@@ -84,11 +96,15 @@ module Clicksend
|
|
|
84
96
|
# A real Clicksend::Client using this fake, with the production retry
|
|
85
97
|
# rules but no backoff delay. A 429's Retry-After is still honoured
|
|
86
98
|
# (injected 429s default to 0 seconds).
|
|
87
|
-
# @param overrides [Hash] any Client.new option
|
|
99
|
+
# @param overrides [Hash] any Client.new option. As with Client.new,
|
|
100
|
+
# +max_retries:+ and +retry_policy:+ together raise ConfigurationError.
|
|
88
101
|
# @return [Clicksend::Client]
|
|
89
102
|
def client(**overrides)
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
defaults = {username: "test", api_key: "test", transport: self}
|
|
104
|
+
unless overrides.key?(:retry_policy)
|
|
105
|
+
retries = {max_retries: overrides.delete(:max_retries)}.compact
|
|
106
|
+
defaults[:retry_policy] = RetryPolicy.new(**retries, base_delay: 0, max_delay: 0)
|
|
107
|
+
end
|
|
92
108
|
Client.new(**defaults.merge(overrides))
|
|
93
109
|
end
|
|
94
110
|
|
|
@@ -98,17 +114,25 @@ module Clicksend
|
|
|
98
114
|
@lock.synchronize { @outbox.map(&:first) }.freeze
|
|
99
115
|
end
|
|
100
116
|
|
|
117
|
+
# Scheduled messages cancelled through PUT /v3/sms/{message_id}/cancel,
|
|
118
|
+
# in the order they were cancelled. They stay in #sent_messages: ClickSend
|
|
119
|
+
# accepted them.
|
|
120
|
+
# @return [Array<SentMessage>] a frozen snapshot
|
|
121
|
+
def cancelled_messages
|
|
122
|
+
@lock.synchronize { @cancelled.values }.freeze
|
|
123
|
+
end
|
|
124
|
+
|
|
101
125
|
# Every request received, oldest first, including failed ones.
|
|
102
126
|
# @return [Array<Request>] a frozen snapshot
|
|
103
127
|
def requests
|
|
104
128
|
@lock.synchronize { @requests.dup }.freeze
|
|
105
129
|
end
|
|
106
130
|
|
|
107
|
-
# Forgets messages, requests, receipts, inbound messages, rejection
|
|
131
|
+
# Forgets messages, cancellations, requests, receipts, inbound messages, rejection
|
|
108
132
|
# rules, pending failures and stubs. Constructor settings are kept.
|
|
109
133
|
# @return [self]
|
|
110
134
|
def reset!
|
|
111
|
-
@lock.synchronize { [@outbox, @requests, @receipts, @latest_receipts, @inbound, @rules, @failures, @stubs].each(&:clear) }
|
|
135
|
+
@lock.synchronize { [@outbox, @cancelled, @requests, @receipts, @latest_receipts, @inbound, @rules, @failures, @stubs].each(&:clear) }
|
|
112
136
|
self
|
|
113
137
|
end
|
|
114
138
|
|
|
@@ -136,10 +160,20 @@ module Clicksend
|
|
|
136
160
|
# fake.fail_next(status: 500, processed: false)
|
|
137
161
|
# fake.fail_next(status: 429, retry_after: 0) # never processed
|
|
138
162
|
# fake.fail_next(status: 401) # any 4xx; never processed
|
|
163
|
+
# fake.fail_next(:interrupted, processed: true) # the worker is stopped mid-send
|
|
139
164
|
#
|
|
140
165
|
# +processed:+ is required exactly when the outcome is ambiguous (a read
|
|
141
|
-
# timeout, a reset or
|
|
142
|
-
# send is recorded), then the failure is returned; with
|
|
166
|
+
# timeout, a reset, a 5xx or an interruption): with +true+ the request is
|
|
167
|
+
# handled first (a send is recorded), then the failure is returned; with
|
|
168
|
+
# +false+ it is not.
|
|
169
|
+
#
|
|
170
|
+
# +:interrupted+ is not a ClickSend failure. It models your job runner
|
|
171
|
+
# stopping the worker during the call (Sidekiq's shutdown raising
|
|
172
|
+
# Sidekiq::Shutdown into busy threads, for example) by raising
|
|
173
|
+
# SimulatedInterrupt, which is not a StandardError: the client lets it
|
|
174
|
+
# through untouched and never retries it. Use it to test what the next
|
|
175
|
+
# run of the job does, e.g. that an in-flight marker stops it sending
|
|
176
|
+
# again.
|
|
143
177
|
#
|
|
144
178
|
# @param path [String, nil] only requests to this exact path
|
|
145
179
|
# @param method [Symbol, nil] only requests with this HTTP method
|
|
@@ -168,6 +202,28 @@ module Clicksend
|
|
|
168
202
|
self
|
|
169
203
|
end
|
|
170
204
|
|
|
205
|
+
# Says what GET /v3/sms/history shows from now on: exactly +messages+
|
|
206
|
+
# (SentMessages from #sent_messages), each with history status +status+,
|
|
207
|
+
# in the order given, or nothing at all. Every history request gets these
|
|
208
|
+
# rows, whatever its q, date or order parameters: you are stating what
|
|
209
|
+
# ClickSend shows for the query your code makes, at that point in your
|
|
210
|
+
# scenario. Call it again to change what history shows. Rows have
|
|
211
|
+
# ClickSend's history shape as observed live (status_code null).
|
|
212
|
+
#
|
|
213
|
+
# fake.stub_history # nothing (yet)
|
|
214
|
+
# fake.stub_history(fake.sent_messages.last) # this message, "Sent"
|
|
215
|
+
# fake.stub_history(message, status: "Cancelled")
|
|
216
|
+
# @return [self]
|
|
217
|
+
def stub_history(*messages, status: "Sent")
|
|
218
|
+
messages.each { |message| sent_message!(message, "messages") }
|
|
219
|
+
unless HISTORY_STATUSES.include?(status)
|
|
220
|
+
raise ArgumentError, "status must be one of ClickSend's outbound history statuses (#{HISTORY_STATUSES.join(", ")}), got #{status.inspect}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
rows = messages.map { |message| Payloads.history_row(message, status, @message_price) }.freeze
|
|
224
|
+
stub(:get, "/v3/sms/history") { |request| Payloads.page(rows, request.path, request.query, "Here are your history.") }
|
|
225
|
+
end
|
|
226
|
+
|
|
171
227
|
# Seeds a delivery receipt, unread. Pass +message_id:+, or +for:+ a
|
|
172
228
|
# SentMessage to take its message_id, custom_string and send time.
|
|
173
229
|
#
|
|
@@ -242,6 +298,7 @@ module Clicksend
|
|
|
242
298
|
# ignored and never stored: they hold the credentials.
|
|
243
299
|
# @return [Clicksend::Transport::Response]
|
|
244
300
|
# @raise [Clicksend::ConnectionError] for injected connection failures
|
|
301
|
+
# @raise [SimulatedInterrupt] for fail_next(:interrupted)
|
|
245
302
|
def call(method, path, query: nil, body: nil, headers: nil)
|
|
246
303
|
request = build_request(method, path, query, body)
|
|
247
304
|
failure, stub = @lock.synchronize do
|
|
@@ -375,6 +432,18 @@ module Clicksend
|
|
|
375
432
|
Payloads.ok("Inbound messages have been marked as read.", entries.size)
|
|
376
433
|
end
|
|
377
434
|
|
|
435
|
+
def cancel_sms(_request, message_id)
|
|
436
|
+
sent, = @outbox.find { |message, _| message.message_id == message_id }
|
|
437
|
+
if sent&.scheduled_at && sent.scheduled_at > now && !@cancelled.key?(message_id)
|
|
438
|
+
@cancelled[message_id] = sent
|
|
439
|
+
return Payloads.ok("Scheduled sms message has been cancelled.", nil)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
raise StubError, "ClickSend doesn't document its answer to cancelling a message that is not scheduled for the future " \
|
|
443
|
+
"(unknown, already sent or already cancelled), so the FakeAPI won't guess: " \
|
|
444
|
+
"stub PUT /v3/sms/#{message_id}/cancel with the answer your test assumes"
|
|
445
|
+
end
|
|
446
|
+
|
|
378
447
|
def mark_read(entries, request, message)
|
|
379
448
|
body = request.body.nil? ? {} : request.body
|
|
380
449
|
cutoff = body["date_before"] if body.is_a?(Hash)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
require_relative "../testing"
|
|
5
|
+
require_relative "sms_expectations"
|
|
6
|
+
|
|
7
|
+
module Clicksend
|
|
8
|
+
module Testing
|
|
9
|
+
# Minitest assertions over a FakeAPI's sent messages. Opt-in (never
|
|
10
|
+
# loaded by +require "clicksend"+); include them where you need them:
|
|
11
|
+
#
|
|
12
|
+
# require "clicksend/testing/minitest"
|
|
13
|
+
#
|
|
14
|
+
# class ActiveSupport::TestCase # or Minitest::Test
|
|
15
|
+
# include Clicksend::Testing::MinitestAssertions
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# assert_sms_sent fake, to: "+61411111111", body: /481516/, custom_string: "otp:42"
|
|
19
|
+
# assert_sms_sent fake, to: "+61411111111", count: 2
|
|
20
|
+
# assert_no_sms_sent fake, to: "+61422222222"
|
|
21
|
+
# assert_no_sms_sent fake
|
|
22
|
+
#
|
|
23
|
+
# They match the attributes of SentMessage (to, body, custom_string,
|
|
24
|
+
# from, list_id, scheduled_at, country, message_id, sent_at) with +===+,
|
|
25
|
+
# so Strings, Regexps and procs work. "Sent" means accepted
|
|
26
|
+
# (fake.sent_messages): rejected recipients don't count.
|
|
27
|
+
module MinitestAssertions
|
|
28
|
+
# Asserts that exactly +count+ sent messages match every given
|
|
29
|
+
# attribute. The default count is 1: a second matching message is the
|
|
30
|
+
# duplicate this assertion is there to catch.
|
|
31
|
+
# @return [Array<SentMessage>] the matching messages
|
|
32
|
+
def assert_sms_sent(fake, count: 1, **attributes)
|
|
33
|
+
clicksend_assert_sms_count(fake, count, attributes, "assert_sms_sent")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Asserts that no message was sent or, given attributes, that none of
|
|
37
|
+
# the sent messages matches them.
|
|
38
|
+
# @return [true]
|
|
39
|
+
def assert_no_sms_sent(fake, **attributes)
|
|
40
|
+
clicksend_assert_sms_count(fake, 0, attributes, "assert_no_sms_sent")
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def clicksend_assert_sms_count(fake, count, attributes, name)
|
|
47
|
+
SmsExpectations.check_attributes!(attributes, name)
|
|
48
|
+
SmsExpectations.check_count!(count, name)
|
|
49
|
+
sent = SmsExpectations.sent_messages(fake, name)
|
|
50
|
+
matched = SmsExpectations.matching(sent, attributes)
|
|
51
|
+
assert(matched.size == count, -> { SmsExpectations.count_failure(attributes, count, matched, sent) })
|
|
52
|
+
matched
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -71,6 +71,21 @@ module Clicksend
|
|
|
71
71
|
}.freeze
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# A GET /v3/sms/history row for a message the fake accepted, in the shape
|
|
75
|
+
# observed live: no gateway code or text, +schedule+ a String.
|
|
76
|
+
def history_row(sent, status, price)
|
|
77
|
+
parts = parts(sent.body)
|
|
78
|
+
{
|
|
79
|
+
"direction" => "out", "date" => sent.sent_at.to_i, "to" => sent.to, "body" => sent.body, "status" => status,
|
|
80
|
+
"from" => sent.from, "schedule" => (sent.scheduled_at || sent.sent_at).to_i.to_s, "status_code" => nil,
|
|
81
|
+
"status_text" => nil, "error_code" => nil, "error_text" => nil, "message_id" => sent.message_id,
|
|
82
|
+
"message_parts" => parts, "message_price" => format("%.4f", Rational(price) * parts), "from_email" => nil,
|
|
83
|
+
"list_id" => sent.list_id, "custom_string" => sent.custom_string || "", "contact_id" => nil, "user_id" => USER_ID,
|
|
84
|
+
"subaccount_id" => SUBACCOUNT_ID, "country" => sent.country, "carrier" => "", "first_name" => nil,
|
|
85
|
+
"last_name" => nil, "_api_username" => "test"
|
|
86
|
+
}.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
74
89
|
# The minimal shape ClickSend returns for a message it did not accept.
|
|
75
90
|
def rejected(message, id, status)
|
|
76
91
|
{
|
|
@@ -9,7 +9,9 @@ module Clicksend
|
|
|
9
9
|
SentMessage = Data.define(:message_id, :to, :from, :body, :custom_string, :list_id, :scheduled_at, :country, :sent_at)
|
|
10
10
|
|
|
11
11
|
# Raised when test code given to the FakeAPI (a #stub block, or the
|
|
12
|
-
# +clock:+) raises a StandardError or ScriptError, i.e. has a bug
|
|
12
|
+
# +clock:+) raises a StandardError or ScriptError, i.e. has a bug, or when
|
|
13
|
+
# a test asks the fake for an answer ClickSend doesn't document (such as
|
|
14
|
+
# cancelling a message that is not scheduled), which it must #stub. It is
|
|
13
15
|
# deliberately not a StandardError, so the client does not report it as a
|
|
14
16
|
# ClickSend failure (connection error, retry, "ambiguous" send): a typo in
|
|
15
17
|
# a stub must fail the test, not satisfy it. Other exceptions (Ctrl-C,
|
|
@@ -17,6 +19,20 @@ module Clicksend
|
|
|
17
19
|
class StubError < Exception # rubocop:disable Lint/InheritException
|
|
18
20
|
end
|
|
19
21
|
|
|
22
|
+
# Raised by FakeAPI#fail_next(:interrupted, processed: ...): the job
|
|
23
|
+
# runner stopping the worker in the middle of a send, before or after
|
|
24
|
+
# ClickSend processed it. It models your job runner (Sidekiq's shutdown
|
|
25
|
+
# raising Sidekiq::Shutdown into busy threads, a deploy's SIGTERM, a
|
|
26
|
+
# timeout), not anything ClickSend does.
|
|
27
|
+
#
|
|
28
|
+
# Like those, it is not a StandardError, so the client lets it through
|
|
29
|
+
# untouched: it is never retried, never wrapped in a Clicksend::Error and
|
|
30
|
+
# never reported as ambiguous. It is not an ::Interrupt either, so a test
|
|
31
|
+
# that doesn't rescue it fails like any other example instead of
|
|
32
|
+
# stopping the test run.
|
|
33
|
+
class SimulatedInterrupt < Exception # rubocop:disable Lint/InheritException
|
|
34
|
+
end
|
|
35
|
+
|
|
20
36
|
# A request the FakeAPI received, recorded whatever its outcome.
|
|
21
37
|
#
|
|
22
38
|
# +http_method+ is a lower-case Symbol; +query+ has String keys and values, as
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
require_relative "../testing"
|
|
5
|
+
require_relative "sms_expectations"
|
|
6
|
+
|
|
7
|
+
module Clicksend
|
|
8
|
+
module Testing
|
|
9
|
+
# RSpec matchers over a FakeAPI's sent messages. Opt-in, from your
|
|
10
|
+
# spec_helper (never loaded by +require "clicksend"+):
|
|
11
|
+
#
|
|
12
|
+
# require "clicksend/testing/rspec"
|
|
13
|
+
#
|
|
14
|
+
# which includes them in every example group (with rspec-core; otherwise
|
|
15
|
+
# include Clicksend::Testing::RSpecMatchers yourself).
|
|
16
|
+
#
|
|
17
|
+
# expect(fake).to have_sent_sms(to: "+61411111111", body: /481516/, custom_string: "otp:42")
|
|
18
|
+
# expect(fake).to have_sent_sms(to: "+61411111111").twice
|
|
19
|
+
# expect(fake).not_to have_sent_sms(to: "+61422222222")
|
|
20
|
+
# expect(fake).to have_sent_no_sms
|
|
21
|
+
#
|
|
22
|
+
# They match the attributes of SentMessage (to, body, custom_string,
|
|
23
|
+
# from, list_id, scheduled_at, country, message_id, sent_at) with +===+,
|
|
24
|
+
# so Strings, Regexps, procs and composable matchers work. "Sent" means
|
|
25
|
+
# accepted (fake.sent_messages): rejected recipients don't count.
|
|
26
|
+
#
|
|
27
|
+
# Without a count, have_sent_sms expects exactly one matching message:
|
|
28
|
+
# a second one is the duplicate these matchers are there to catch.
|
|
29
|
+
module RSpecMatchers
|
|
30
|
+
# Passes when exactly one sent message (or the chained count) matches
|
|
31
|
+
# every given attribute. Negated, passes when none matches.
|
|
32
|
+
# @return [HaveSentSms]
|
|
33
|
+
def have_sent_sms(**attributes)
|
|
34
|
+
HaveSentSms.new(attributes)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Passes when no message was sent or, given attributes, none of the
|
|
38
|
+
# sent messages matches them.
|
|
39
|
+
# @return [HaveSentSms]
|
|
40
|
+
def have_sent_no_sms(**attributes)
|
|
41
|
+
HaveSentSms.new(attributes, count: 0, name: "have_sent_no_sms")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The matcher behind #have_sent_sms and #have_sent_no_sms.
|
|
45
|
+
class HaveSentSms
|
|
46
|
+
include ::RSpec::Matchers::Composable
|
|
47
|
+
|
|
48
|
+
def initialize(attributes, count: nil, name: "have_sent_sms")
|
|
49
|
+
SmsExpectations.check_attributes!(attributes, name)
|
|
50
|
+
@attributes = attributes
|
|
51
|
+
@count = count
|
|
52
|
+
@name = name
|
|
53
|
+
@fixed = !count.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [self]
|
|
57
|
+
def once = exactly(1)
|
|
58
|
+
|
|
59
|
+
# @return [self]
|
|
60
|
+
def twice = exactly(2)
|
|
61
|
+
|
|
62
|
+
# +exactly(n).times+, or just +times(n)+.
|
|
63
|
+
# @return [self]
|
|
64
|
+
def exactly(count)
|
|
65
|
+
raise ArgumentError, "#{@name} takes no count; use have_sent_sms(...).exactly(n).times" if @fixed
|
|
66
|
+
|
|
67
|
+
SmsExpectations.check_count!(count, @name)
|
|
68
|
+
@count = count
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [self]
|
|
73
|
+
def times(count = nil)
|
|
74
|
+
return exactly(count) unless count.nil?
|
|
75
|
+
raise ArgumentError, "use have_sent_sms(...).times(n) or .exactly(n).times" if @count.nil?
|
|
76
|
+
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def matches?(fake)
|
|
81
|
+
evaluate(fake)
|
|
82
|
+
@matched.size == expected_count
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def does_not_match?(fake)
|
|
86
|
+
if @fixed || !@count.nil?
|
|
87
|
+
raise ArgumentError, "#{@name}#{" with a count" unless @fixed} can't be negated: " \
|
|
88
|
+
"say how many you expect, e.g. to have_sent_sms(...).exactly(0).times"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
evaluate(fake)
|
|
92
|
+
@matched.empty?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def failure_message
|
|
96
|
+
SmsExpectations.count_failure(@attributes, expected_count, @matched, @sent)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def failure_message_when_negated
|
|
100
|
+
SmsExpectations.count_failure(@attributes, 0, @matched, @sent)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def description
|
|
104
|
+
return "have sent no #{SmsExpectations.subject(@attributes)}" if expected_count.zero?
|
|
105
|
+
|
|
106
|
+
"have sent exactly #{expected_count} #{SmsExpectations.subject(@attributes)}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def expected_count = @count || 1
|
|
112
|
+
|
|
113
|
+
def evaluate(fake)
|
|
114
|
+
@sent = SmsExpectations.sent_messages(fake, @name)
|
|
115
|
+
@matched = SmsExpectations.matching(@sent, @attributes)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if defined?(RSpec.configure)
|
|
123
|
+
RSpec.configure { |config| config.include(Clicksend::Testing::RSpecMatchers) }
|
|
124
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../testing"
|
|
4
|
+
|
|
5
|
+
module Clicksend
|
|
6
|
+
module Testing
|
|
7
|
+
# The matching and failure output shared by the RSpec matchers
|
|
8
|
+
# (clicksend/testing/rspec) and the Minitest assertions
|
|
9
|
+
# (clicksend/testing/minitest). Framework-free.
|
|
10
|
+
# @api private
|
|
11
|
+
module SmsExpectations
|
|
12
|
+
ATTRIBUTES = SentMessage.members.freeze
|
|
13
|
+
# Failure output lists at most this many messages...
|
|
14
|
+
MAX_LISTED = 10
|
|
15
|
+
# ...and shortens longer values to this many characters.
|
|
16
|
+
MAX_VALUE_LENGTH = 60
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @return [Array<SentMessage>]
|
|
21
|
+
def sent_messages(fake, helper)
|
|
22
|
+
return fake.sent_messages if fake.is_a?(FakeAPI)
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "#{helper} expects a Clicksend::Testing::FakeAPI (the fake itself, not fake.client " \
|
|
25
|
+
"or fake.sent_messages), got #{fake.class}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Keys must be SentMessage attributes: a typo must fail the test, not
|
|
29
|
+
# match everything.
|
|
30
|
+
def check_attributes!(attributes, helper)
|
|
31
|
+
unknown = attributes.keys - ATTRIBUTES
|
|
32
|
+
return if unknown.empty?
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "#{helper}: unknown attribute#{"s" if unknown.size > 1} #{unknown.map(&:inspect).join(", ")}; " \
|
|
35
|
+
"use #{ATTRIBUTES.join(", ")}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def check_count!(count, helper)
|
|
39
|
+
raise ArgumentError, "#{helper}: the count must be a non-negative Integer, got #{count.inspect}" unless count.is_a?(Integer) && count >= 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Each expected value is matched with +===+, so Strings, Regexps,
|
|
43
|
+
# Ranges, classes, procs and RSpec's composable matchers all work.
|
|
44
|
+
# @return [Array<SentMessage>]
|
|
45
|
+
def matching(messages, attributes)
|
|
46
|
+
messages.select { |message| attributes.all? { |key, expected| expected === message.public_send(key) } }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# "SMS matching to: "+61411111111", body: /code/" or "SMS".
|
|
50
|
+
def subject(attributes)
|
|
51
|
+
return "SMS" if attributes.empty?
|
|
52
|
+
|
|
53
|
+
"SMS matching #{attributes.map { |key, value| "#{key}: #{describe_value(value)}" }.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The failure when +count+ messages were expected and +matched+ were found.
|
|
57
|
+
def count_failure(attributes, count, matched, messages)
|
|
58
|
+
expectation = count.zero? ? "no #{subject(attributes)}" : "exactly #{count} #{subject(attributes)}"
|
|
59
|
+
"expected #{expectation} to have been sent, but #{matched.size} matched.\n#{listing(messages, attributes)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# "No SMS was sent." or "3 SMS sent:" and one line per message (at
|
|
63
|
+
# most MAX_LISTED).
|
|
64
|
+
def listing(messages, attributes)
|
|
65
|
+
return "No SMS was sent." if messages.empty?
|
|
66
|
+
|
|
67
|
+
lines = messages.first(MAX_LISTED).each_with_index.map { |message, index| " #{index + 1}. #{line(message, attributes)}" }
|
|
68
|
+
lines << " ... and #{messages.size - MAX_LISTED} more" if messages.size > MAX_LISTED
|
|
69
|
+
"#{messages.size} SMS sent:\n#{lines.join("\n")}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# One message on one line: recipient (or list_id), custom_string and
|
|
73
|
+
# body, then any other attribute the expectation names.
|
|
74
|
+
def line(message, attributes)
|
|
75
|
+
keys = [message.to.nil? ? :list_id : :to, :custom_string, :body] | attributes.keys
|
|
76
|
+
keys.map { |key| "#{key}: #{show(message.public_send(key))}" }.join(", ")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def describe_value(value)
|
|
80
|
+
value.respond_to?(:description) ? value.description : show(value)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A long String is cut, with the "..." outside its quotes.
|
|
84
|
+
def show(value)
|
|
85
|
+
return value.inspect unless value.is_a?(String) && value.length > MAX_VALUE_LENGTH
|
|
86
|
+
|
|
87
|
+
"#{value[0, MAX_VALUE_LENGTH].inspect}..."
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/clicksend/testing.rb
CHANGED
|
@@ -83,6 +83,12 @@ module Clicksend
|
|
|
83
83
|
#
|
|
84
84
|
# A FakeAPI keeps every request in memory until #reset!, so a long-running
|
|
85
85
|
# process should reset it from time to time.
|
|
86
|
+
#
|
|
87
|
+
# Assertions for test frameworks are separate, opt-in files, and this gem
|
|
88
|
+
# depends on neither framework: +require "clicksend/testing/rspec"+ adds
|
|
89
|
+
# +have_sent_sms+ and +have_sent_no_sms+ (Clicksend::Testing::RSpecMatchers),
|
|
90
|
+
# +require "clicksend/testing/minitest"+ adds +assert_sms_sent+ and
|
|
91
|
+
# +assert_no_sms_sent+ (Clicksend::Testing::MinitestAssertions).
|
|
86
92
|
module Testing
|
|
87
93
|
end
|
|
88
94
|
end
|
data/lib/clicksend/transport.rb
CHANGED
|
@@ -21,11 +21,16 @@ module Clicksend
|
|
|
21
21
|
|
|
22
22
|
# The default transport, built on Faraday 2.
|
|
23
23
|
class Faraday
|
|
24
|
-
# Failures that can only happen
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
|
|
24
|
+
# Failures that can only happen before the request is written, so the
|
|
25
|
+
# request cannot have reached ClickSend: connecting (refused, DNS,
|
|
26
|
+
# connect or TLS-handshake timeout) and, with adapter:
|
|
27
|
+
# :net_http_persistent, waiting for a pooled connection
|
|
28
|
+
# (ConnectionPool::TimeoutError, raised by the checkout that precedes
|
|
29
|
+
# everything else). Matched by name, so no adapter is required.
|
|
30
|
+
# Deliberately narrow: TLS errors and unreachable or downed hosts can
|
|
31
|
+
# also occur after the request was sent, so they count as "may have
|
|
32
|
+
# been sent".
|
|
33
|
+
NOT_SENT_ERRORS = ["Errno::ECONNREFUSED", "SocketError", "Net::OpenTimeout", "ConnectionPool::TimeoutError"].freeze
|
|
29
34
|
|
|
30
35
|
# @param adapter [Symbol, Array, nil] a Faraday adapter name, optionally
|
|
31
36
|
# with arguments (e.g. +[:net_http_persistent, {pool_size: 5}]+).
|
|
@@ -46,7 +51,7 @@ module Clicksend
|
|
|
46
51
|
body: response.body.to_s
|
|
47
52
|
)
|
|
48
53
|
rescue ::Faraday::TimeoutError => e
|
|
49
|
-
raise Clicksend::TimeoutError
|
|
54
|
+
raise Clicksend::TimeoutError.new("Timed out waiting for ClickSend: #{e.message}", request_sent: not_sent?(e) ? false : nil)
|
|
50
55
|
rescue ::Faraday::ConnectionFailed, ::Faraday::SSLError => e
|
|
51
56
|
raise translate_connection_failure(e)
|
|
52
57
|
end
|
|
@@ -54,10 +59,25 @@ module Clicksend
|
|
|
54
59
|
private
|
|
55
60
|
|
|
56
61
|
def translate_connection_failure(error)
|
|
62
|
+
error_class = (failure_cause(error)&.class&.name == "Net::OpenTimeout") ? Clicksend::TimeoutError : Clicksend::ConnectionError
|
|
63
|
+
error_class.new("Could not reach ClickSend: #{error.message}", request_sent: not_sent?(error) ? false : nil)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def not_sent?(error)
|
|
67
|
+
cause = failure_cause(error)
|
|
68
|
+
!cause.nil? && cause.class.ancestors.any? { |klass| NOT_SENT_ERRORS.include?(klass.name) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The exception the adapter wrapped. net-http-persistent reports a
|
|
72
|
+
# refused connection (only ever raised by connect(2), never once the
|
|
73
|
+
# request is being written) as a Net::HTTP::Persistent::Error raised
|
|
74
|
+
# in its rescue of the Errno, so for that class the Errno, its #cause,
|
|
75
|
+
# is what happened. Its other errors ("host down: ...") keep their
|
|
76
|
+
# own cause, which is not in NOT_SENT_ERRORS.
|
|
77
|
+
def failure_cause(error)
|
|
57
78
|
cause = error.wrapped_exception || error.cause
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
error_class.new("Could not reach ClickSend: #{error.message}", request_sent: not_sent ? false : nil)
|
|
79
|
+
persistent = defined?(::Net::HTTP::Persistent::Error) && cause.is_a?(::Net::HTTP::Persistent::Error)
|
|
80
|
+
persistent ? cause.cause : cause
|
|
61
81
|
end
|
|
62
82
|
end
|
|
63
83
|
end
|
data/lib/clicksend/version.rb
CHANGED
data/lib/clicksend/webhook.rb
CHANGED
|
@@ -10,27 +10,33 @@ module Clicksend
|
|
|
10
10
|
#
|
|
11
11
|
# ClickSend pushes through automation rules with the +URL+ action. Inbound
|
|
12
12
|
# rules POST a form (the default), GET with a query string, or POST JSON, by
|
|
13
|
-
# the rule's +webhook_type
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# +status+ ("Delivered"/"Undelivered")
|
|
13
|
+
# the rule's +webhook_type+. Receipt rules have no +webhook_type+; archived
|
|
14
|
+
# ClickSend help says pushes are form-encoded. The field names are those of
|
|
15
|
+
# the poll schemas (+sms_receipt+, +inbound_sms+), which an archived
|
|
16
|
+
# ClickSend help article and ClickSend's own n8n and Power Automate
|
|
17
|
+
# integrations also use. Those sources show pushes also carry +user_id+,
|
|
18
|
+
# +status+ ("Delivered"/"Undelivered", receipts) and legacy duplicates such
|
|
19
|
+
# as +message+, +sms+, +originalsenderid+, +messageid+ and +customstring+;
|
|
20
|
+
# these stay in +raw+. A JSON push may send numbers (+timestamp+,
|
|
21
|
+
# +user_id+) as JSON integers; both forms are accepted.
|
|
19
22
|
#
|
|
20
|
-
# ClickSend
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# - put an unguessable secret in the URL
|
|
23
|
+
# ClickSend's current docs describe no signing or authentication for
|
|
24
|
+
# pushes: no HMAC, signature or shared secret. (An archived help article
|
|
25
|
+
# listed six source IP addresses; it is no longer published, so do not rely
|
|
26
|
+
# on it.) Treat anyone who knows the URL as able to forge one, so:
|
|
27
|
+
# - put an unguessable secret in the URL and compare it in constant time
|
|
25
28
|
# (it will appear in access logs: restrict who reads them);
|
|
26
|
-
# - use HTTPS;
|
|
29
|
+
# - use HTTPS (archived help: a valid certificate chain is required);
|
|
27
30
|
# - treat the event as a hint; a receipt can probably be confirmed with
|
|
28
31
|
# <tt>client.sms.receipt(event.message_id)</tt> (not yet verified for
|
|
29
32
|
# accounts with only URL rules);
|
|
30
|
-
# - process idempotently: several matching rules may each push, and
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
33
|
+
# - process idempotently: several matching rules may each push, and a
|
|
34
|
+
# non-200 or slow answer is retried (archived pages disagree on the
|
|
35
|
+
# schedule: every 10 minutes 10 times, or backing off over hours). An
|
|
36
|
+
# inbound message_id is unique, but one sent message may get more than
|
|
37
|
+
# one receipt (the pending codes 200 and 300 "can update at any time"), so
|
|
38
|
+
# key receipts on message_id and status_code, and never let a pending
|
|
39
|
+
# status replace a final one;
|
|
34
40
|
# - answer 200 quickly and do the work in a job.
|
|
35
41
|
#
|
|
36
42
|
# Prefer one URL per rule type with #parse_receipt / #parse_inbound; #parse
|