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.
@@ -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, and receipts and inbound (list, one
14
- # receipt, mark read). Anything else answers 404 unless #stub-bed.
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 doesn't
17
- # say how soon a sent message appears there, and an always-up-to-date fake
18
- # history would let a "not in history, so resend" rule pass its tests and
19
- # then send twice in production. To test reconciliation code, #stub the
20
- # history rows your scenario needs, including none. Recipients can be rejected (#reject), failures
21
- # injected (#fail_next) and receipts and replies seeded (#add_receipt,
22
- # #add_inbound).
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
- retries = overrides.key?(:max_retries) ? {max_retries: overrides.delete(:max_retries)} : {}
91
- defaults = {username: "test", api_key: "test", transport: self, retry_policy: RetryPolicy.new(**retries, base_delay: 0, max_delay: 0)}
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 a 5xx): with +true+ the request is handled first (a
142
- # send is recorded), then the failure is returned; with +false+ it is not.
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. It is
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
@@ -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
@@ -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 while connecting, before the request is
25
- # written, so the request cannot have reached ClickSend. Deliberately
26
- # narrow: TLS errors and unreachable-host errors can also occur after
27
- # the request was sent, so they count as "may have been sent".
28
- NOT_SENT_ERRORS = ["Errno::ECONNREFUSED", "SocketError", "Net::OpenTimeout"].freeze
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, "Timed out waiting for ClickSend: #{e.message}"
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
- not_sent = cause && cause.class.ancestors.any? { |klass| NOT_SENT_ERRORS.include?(klass.name) }
59
- error_class = (cause&.class&.name == "Net::OpenTimeout") ? Clicksend::TimeoutError : Clicksend::ConnectionError
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clicksend
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -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+; ClickSend does not document the JSON field
14
- # names, and this assumes they are the same. Receipt pushes are form-encoded
15
- # according to the archived v3 docs. The field names come from the poll
16
- # schemas (+sms_receipt+, +inbound_sms+), which match the archived push
17
- # docs. According to those, pushes also carry +user_id+ and, on receipts,
18
- # +status+ ("Delivered"/"Undelivered"); those stay in +raw+.
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 documents no signing or authentication for pushes: no HMAC,
21
- # signature or secret, and no current list of source IP addresses (archived
22
- # help pages once listed some; they can't be checked today, so don't
23
- # allowlist by IP). Treat anyone who knows the URL as able to forge one, so:
24
- # - put an unguessable secret in the URL path and compare it in constant time
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 (per the
31
- # archived docs) a non-200 is retried every 10 minutes, 10 times. Key inbound
32
- # messages on +message_id+, and receipts on +message_id+ and +status_code+:
33
- # non-final codes (200, 300) mean one message can get several receipts;
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