clicksend 1.0.0 → 1.1.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.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ module SMS
5
+ # One row of SMS history (GET /v3/sms/history): a message you sent
6
+ # (+direction+ "out") or received ("in").
7
+ #
8
+ # +status+ is ClickSend's history status ("Queued", "Sent", "Completed",
9
+ # "Scheduled", "WaitApproval", "Failed", "Cancelled", "CancelledAfterReview",
10
+ # "Received"), which is not the send-time status of SMS::Message.
11
+ # +status_code+ is the gateway code also used by receipts (see
12
+ # SMS::Receipt): 200 not final, 201 delivered, 300 retrying, 301 failed.
13
+ # It can be nil: a test-number message was observed as "Completed" with no
14
+ # code.
15
+ #
16
+ # The predicates follow ClickSend's "SMS error codes" article (help 42318).
17
+ # The gateway code decides when present. Without one, only statuses whose
18
+ # code that article fixes are used: "Queued", "Scheduled" and
19
+ # "WaitApproval" are always 200 (pending); "Failed" and "Cancelled" are
20
+ # 301 and "CancelledAfterReview" never reached the network (failed). A
21
+ # "Sent" row can be 200 or 201, and "Completed" isn't in the article, so
22
+ # without a code neither is known: all three predicates are false.
23
+ # History statuses whose gateway code help 42318 fixes (see HistoryRecord).
24
+ HISTORY_PENDING_STATUSES = %w[Queued Scheduled WaitApproval].freeze
25
+ HISTORY_FAILED_STATUSES = %w[Failed Cancelled CancelledAfterReview].freeze
26
+ private_constant :HISTORY_PENDING_STATUSES, :HISTORY_FAILED_STATUSES
27
+
28
+ HistoryRecord = Data.define(
29
+ :message_id, :direction, :status, :status_code, :status_text, :error_code, :error_text,
30
+ :to, :from, :body, :parts, :price, :custom_string, :list_id, :country, :carrier,
31
+ :sent_at, :scheduled_at, :raw
32
+ ) do
33
+ include Model::Inspect
34
+
35
+ def self.from_api(payload)
36
+ payload = Model.payload!(payload, "history record")
37
+ scheduled = Model.integer(payload["schedule"])
38
+ new(
39
+ message_id: Model.string(payload["message_id"]),
40
+ direction: Model.string(payload["direction"]),
41
+ status: Model.string(payload["status"]),
42
+ status_code: Model.integer(payload["status_code"]),
43
+ status_text: Model.string(payload["status_text"]),
44
+ error_code: Model.code(payload["error_code"]),
45
+ error_text: Model.string(payload["error_text"]),
46
+ to: Model.string(payload["to"]),
47
+ from: Model.string(payload["from"]),
48
+ body: Model.string(payload["body"]),
49
+ parts: Model.integer(payload["message_parts"]),
50
+ price: Model.decimal(payload["message_price"]),
51
+ custom_string: Model.string(payload["custom_string"]),
52
+ list_id: Model.code(payload["list_id"]),
53
+ country: Model.string(payload["country"]),
54
+ carrier: Model.string(payload["carrier"]),
55
+ sent_at: Model.time(payload["date"]),
56
+ scheduled_at: (Time.at(scheduled).utc if scheduled&.positive?),
57
+ raw: payload
58
+ )
59
+ end
60
+
61
+ def outbound?
62
+ direction == "out"
63
+ end
64
+
65
+ def inbound?
66
+ direction == "in"
67
+ end
68
+
69
+ # Delivered to the handset (gateway code 201).
70
+ def delivered?
71
+ status_code == 201
72
+ end
73
+
74
+ # Final and not delivered: code 301, or a failed or cancelled status.
75
+ def failed?
76
+ status_code.nil? ? HISTORY_FAILED_STATUSES.include?(status) : status_code == 301
77
+ end
78
+
79
+ # Not final yet: code 200 or 300, or a status that is always 200.
80
+ def pending?
81
+ status_code.nil? ? HISTORY_PENDING_STATUSES.include?(status) : [200, 300].include?(status_code)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -29,7 +29,7 @@ module Clicksend
29
29
  parts: Model.integer(payload["message_parts"]),
30
30
  price: Model.decimal(payload["message_price"]),
31
31
  custom_string: Model.string(payload["custom_string"]),
32
- list_id: Model.string(payload["list_id"]) || Model.integer(payload["list_id"])&.to_s,
32
+ list_id: Model.code(payload["list_id"]),
33
33
  country: Model.string(payload["country"]),
34
34
  carrier: Model.string(payload["carrier"]),
35
35
  sent_at: Model.time(payload["date"]),
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ module Testing
5
+ # One FakeAPI#fail_next instruction: what fails, which requests it
6
+ # applies to, and whether ClickSend processed the request first.
7
+ # @api private
8
+ class Failure
9
+ # outcome => [error class, request_sent, message]
10
+ CONNECTION = {
11
+ connection_refused: [ConnectionError, false, "Connection refused"],
12
+ open_timeout: [TimeoutError, false, "Timed out opening the connection"],
13
+ timeout: [TimeoutError, nil, "Timed out waiting for the response"],
14
+ connection_reset: [ConnectionError, nil, "Connection reset by peer"]
15
+ }.freeze
16
+
17
+ attr_reader :times
18
+
19
+ def initialize(outcome, status:, processed:, retry_after:, path:, method:, times:)
20
+ label = outcome ? outcome.inspect : "status: #{status.inspect}"
21
+ if outcome.nil? == status.nil?
22
+ raise ArgumentError, "fail_next needs an outcome (#{CONNECTION.keys.map(&:inspect).join(", ")}) or status:, not both"
23
+ end
24
+ raise ArgumentError, "retry_after: only applies to status: 429" if retry_after && status != 429
25
+
26
+ if outcome
27
+ @error_class, @request_sent, @message = CONNECTION.fetch(outcome) do
28
+ raise ArgumentError, "unknown fail_next outcome #{outcome.inspect}; use one of #{CONNECTION.keys.map(&:inspect).join(", ")} or status:"
29
+ end
30
+ ambiguous = @request_sent.nil?
31
+ else
32
+ raise ArgumentError, "status must be an Integer HTTP error status (400..599), got #{status.inspect}" unless status.is_a?(Integer) && (400..599).cover?(status)
33
+ if !retry_after.nil? && !(retry_after.is_a?(Integer) && retry_after >= 0)
34
+ raise ArgumentError, "retry_after must be a non-negative Integer (seconds)"
35
+ end
36
+ @status = status
37
+ @retry_after = retry_after || 0
38
+ ambiguous = status >= 500
39
+ end
40
+
41
+ if ambiguous && ![true, false].include?(processed)
42
+ raise ArgumentError, "fail_next(#{label}) needs processed: true or false: did ClickSend act on the request before the failure?"
43
+ elsif !ambiguous && !processed.nil?
44
+ raise ArgumentError, "processed: does not apply to fail_next(#{label}): such a request is never processed"
45
+ end
46
+ raise ArgumentError, "path must be a String such as \"/v3/sms/send\"" if !path.nil? && !(path.is_a?(String) && path.start_with?("/"))
47
+ unless method.nil? || Connection::HTTP_METHODS.include?(method.to_s.downcase.to_sym)
48
+ raise ArgumentError, "method must be one of #{Connection::HTTP_METHODS.join(", ")}"
49
+ end
50
+ raise ArgumentError, "times must be a positive Integer" unless times.is_a?(Integer) && times.positive?
51
+
52
+ @processed = processed == true
53
+ @path = path
54
+ @method = method&.to_s&.downcase&.to_sym
55
+ @times = times
56
+ freeze
57
+ end
58
+
59
+ # Whether ClickSend acts on the request before the failure.
60
+ def processed?
61
+ @processed
62
+ end
63
+
64
+ def matches?(request)
65
+ (@path.nil? || @path == request.path) && (@method.nil? || @method == request.http_method)
66
+ end
67
+
68
+ # Raises the connection error, or returns the error response.
69
+ # @return [Clicksend::Transport::Response]
70
+ def trigger
71
+ raise @error_class.new("#{@message} (simulated by Clicksend::Testing::FakeAPI)", request_sent: @request_sent) if @error_class
72
+ return Payloads.error(@status) unless @status == 429
73
+
74
+ Payloads.error(429, nil, nil, {
75
+ "retry-after" => @retry_after.to_s, "x-ratelimit-limit" => "20", "x-ratelimit-remaining" => "0",
76
+ "ratelimit-reset" => @retry_after.to_s
77
+ })
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ module Testing
5
+ # An in-memory ClickSend, used as a Client's transport.
6
+ #
7
+ # fake = Clicksend::Testing::FakeAPI.new
8
+ # client = fake.client # or Client.new(username: "u", api_key: "k", transport: fake)
9
+ # client.sms.deliver(to: "+61411111111", body: "Hi", custom_string: "otp:42")
10
+ # fake.sent_messages.last.custom_string # => "otp:42"
11
+ #
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.
15
+ #
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).
23
+ #
24
+ # Simplifications, so tests don't come to depend on them:
25
+ # - the balance never changes; +message_parts+ is an estimate (one per
26
+ # 160 characters);
27
+ # - a recipient that is not 6 to 15 digits (optionally after "+") gets
28
+ # "INVALID_RECIPIENT"; the real rules are ClickSend's own;
29
+ # - mark-read with +date_before+ marks items whose +timestamp+ is strictly
30
+ # earlier (ClickSend does not document whether the cutoff is inclusive).
31
+ #
32
+ # Exceptions raised by #stub blocks or the +clock:+ surface as
33
+ # Testing::StubError, never as a simulated ClickSend failure.
34
+ #
35
+ # Thread-safe. Stub blocks run outside the lock, so they may call the fake.
36
+ class FakeAPI
37
+ Entry = Struct.new(:payload, :read)
38
+ private_constant :Entry
39
+
40
+ DECIMAL = /\A\d+(\.\d+)?\z/
41
+ RECIPIENT = /\A\+?\d{6,15}\z/
42
+ # What a bug in a stub block or clock raises. Anything else (Interrupt,
43
+ # SystemExit, Timeout's internal exception, RSpec or Minitest assertion
44
+ # failures) is not a mistake in the fake's setup and passes through as is.
45
+ MISTAKES = [StandardError, ScriptError].freeze
46
+ STATUS_TEXTS = {200 => "Sent", 201 => "Delivered", 300 => "Retrying", 301 => "Failed"}.freeze
47
+ ROUTES = [
48
+ [:post, %r{\A/v3/sms/send\z}, :send_sms],
49
+ [:get, %r{\A/v3/account\z}, :account],
50
+ [:get, %r{\A/v3/sms/receipts\z}, :list_receipts],
51
+ [:get, %r{\A/v3/sms/receipts/([A-Za-z0-9-]+)\z}, :show_receipt],
52
+ [:put, %r{\A/v3/sms/receipts-read\z}, :mark_receipts_read],
53
+ [:get, %r{\A/v3/sms/inbound\z}, :list_inbound],
54
+ [: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]
56
+ ].freeze
57
+ private_constant :DECIMAL, :RECIPIENT, :MISTAKES, :STATUS_TEXTS, :ROUTES
58
+
59
+ # @param balance [String] the account balance, as ClickSend's decimal String
60
+ # @param currency [String] e.g. "AUD"
61
+ # @param message_price [String] price per message part, e.g. "0.0792"
62
+ # @param clock [#call] returns the current Time; pass a fixed one to freeze time
63
+ def initialize(balance: "10.000000", currency: "AUD", message_price: "0.0000", clock: -> { Time.now })
64
+ raise ArgumentError, "balance must be a decimal String such as \"10.000000\"" unless balance.is_a?(String) && balance.match?(DECIMAL)
65
+ raise ArgumentError, "message_price must be a decimal String such as \"0.0792\"" unless message_price.is_a?(String) && message_price.match?(DECIMAL)
66
+ raise ArgumentError, "currency must be a currency code String such as \"AUD\"" unless currency.is_a?(String) && !currency.empty?
67
+ raise ArgumentError, "clock must respond to #call and return a Time" unless clock.respond_to?(:call)
68
+
69
+ @balance = balance.dup.freeze
70
+ @currency = currency.dup.freeze
71
+ @message_price = message_price.dup.freeze
72
+ @clock = clock
73
+ @lock = Mutex.new
74
+ @outbox = [] # [SentMessage, accepted payload]
75
+ @requests = []
76
+ @receipts = []
77
+ @latest_receipts = {} # message_id => payload
78
+ @inbound = []
79
+ @rules = []
80
+ @failures = [] # [Failure, remaining]
81
+ @stubs = {}
82
+ end
83
+
84
+ # A real Clicksend::Client using this fake, with the production retry
85
+ # rules but no backoff delay. A 429's Retry-After is still honoured
86
+ # (injected 429s default to 0 seconds).
87
+ # @param overrides [Hash] any Client.new option
88
+ # @return [Clicksend::Client]
89
+ 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)}
92
+ Client.new(**defaults.merge(overrides))
93
+ end
94
+
95
+ # Messages accepted (status "SUCCESS"), oldest first.
96
+ # @return [Array<SentMessage>] a frozen snapshot
97
+ def sent_messages
98
+ @lock.synchronize { @outbox.map(&:first) }.freeze
99
+ end
100
+
101
+ # Every request received, oldest first, including failed ones.
102
+ # @return [Array<Request>] a frozen snapshot
103
+ def requests
104
+ @lock.synchronize { @requests.dup }.freeze
105
+ end
106
+
107
+ # Forgets messages, requests, receipts, inbound messages, rejection
108
+ # rules, pending failures and stubs. Constructor settings are kept.
109
+ # @return [self]
110
+ def reset!
111
+ @lock.synchronize { [@outbox, @requests, @receipts, @latest_receipts, @inbound, @rules, @failures, @stubs].each(&:clear) }
112
+ self
113
+ end
114
+
115
+ # Gives every later message to +to+ (or, without +to+, every message)
116
+ # the per-message status +status+ instead of "SUCCESS". The most recent
117
+ # matching rule wins. A single #deliver then raises MessageRejected.
118
+ # "COUNTRY_NOT_ENABLED" rejections are counted in +blocked_count+.
119
+ # @return [self]
120
+ def reject(status:, to: nil)
121
+ unless status.is_a?(String) && status.match?(/\A[A-Z][A-Z0-9_]*\z/) && status != "SUCCESS"
122
+ raise ArgumentError, "status must be a ClickSend per-message status such as \"INVALID_RECIPIENT\", got #{status.inspect}"
123
+ end
124
+ raise ArgumentError, "to must be a phone number String, or nil for every recipient" unless to.nil? || to.is_a?(String)
125
+
126
+ @lock.synchronize { @rules << [to, status].freeze }
127
+ self
128
+ end
129
+
130
+ # Makes the next matching request(s) fail, oldest instruction first.
131
+ #
132
+ # fake.fail_next(:connection_refused) # never sent: retried by the client
133
+ # fake.fail_next(:open_timeout) # never sent: retried by the client
134
+ # fake.fail_next(:timeout, processed: true) # accepted, response lost
135
+ # fake.fail_next(:connection_reset, processed: false)
136
+ # fake.fail_next(status: 500, processed: false)
137
+ # fake.fail_next(status: 429, retry_after: 0) # never processed
138
+ # fake.fail_next(status: 401) # any 4xx; never processed
139
+ #
140
+ # +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.
143
+ #
144
+ # @param path [String, nil] only requests to this exact path
145
+ # @param method [Symbol, nil] only requests with this HTTP method
146
+ # @param times [Integer] how many matching requests fail
147
+ # @return [self]
148
+ def fail_next(outcome = nil, status: nil, processed: nil, retry_after: nil, path: nil, method: nil, times: 1)
149
+ failure = Failure.new(outcome, status: status, processed: processed, retry_after: retry_after, path: path, method: method, times: times)
150
+ @lock.synchronize { @failures << [failure, failure.times] }
151
+ self
152
+ end
153
+
154
+ # Answers +method+ +path+ (exact path, no query) with the block's result,
155
+ # in place of the built-in endpoint or the 404. The block receives a
156
+ # Request and returns a Hash (sent as a 200 envelope, unless it has its
157
+ # own "http_code") or a Clicksend::Transport::Response.
158
+ #
159
+ # fake.stub(:get, "/v3/sms/templates") { |request| {"data" => {"data" => []}} }
160
+ # @return [self]
161
+ def stub(method, path, &block)
162
+ method = method.to_s.downcase.to_sym
163
+ raise ArgumentError, "method must be one of #{Connection::HTTP_METHODS.join(", ")}" unless Connection::HTTP_METHODS.include?(method)
164
+ raise ArgumentError, "path must be a String such as \"/v3/sms/templates\", without a query" unless path.is_a?(String) && path.start_with?("/") && !path.include?("?")
165
+ raise ArgumentError, "stub needs a block" unless block
166
+
167
+ @lock.synchronize { @stubs[[method, path]] = block }
168
+ self
169
+ end
170
+
171
+ # Seeds a delivery receipt, unread. Pass +message_id:+, or +for:+ a
172
+ # SentMessage to take its message_id, custom_string and send time.
173
+ #
174
+ # fake.add_receipt(for: fake.sent_messages.last, status_code: 301, error_code: 3, error_text: "Expired")
175
+ #
176
+ # @param status_code [Integer] gateway code: 200, 201 (delivered), 300, 301 (failed)
177
+ # @param status_text [String, nil] defaults to a word for the code ("Delivered", "Failed", ...)
178
+ # @param timestamp [Time, Integer, nil] when the receipt arrived (default: now)
179
+ # @param timestamp_send [Time, Integer, nil] when the message was sent (default: +timestamp+)
180
+ # @return [Clicksend::SMS::Receipt] built from the payload the API returns
181
+ def add_receipt(message_id: nil, for: nil, status_code: 201, status_text: nil, error_code: nil, error_text: nil,
182
+ custom_string: nil, timestamp: nil, timestamp_send: nil)
183
+ sent = binding.local_variable_get(:for)
184
+ raise ArgumentError, "pass message_id: or for:, not both" if sent && message_id
185
+ if sent
186
+ sent_message!(sent, "for")
187
+ message_id = sent.message_id
188
+ custom_string ||= sent.custom_string
189
+ timestamp_send ||= sent.sent_at
190
+ end
191
+ message_id!(message_id)
192
+ raise ArgumentError, "status_code must be an Integer gateway code such as 201" unless status_code.is_a?(Integer)
193
+ raise ArgumentError, "error_code must be an Integer or nil" unless error_code.nil? || error_code.is_a?(Integer)
194
+ strings!(status_text: status_text, error_text: error_text, custom_string: custom_string)
195
+
196
+ received = unix(timestamp || now, "timestamp")
197
+ payload = {
198
+ "timestamp_send" => timestamp_send ? unix(timestamp_send, "timestamp_send") : received, "timestamp" => received,
199
+ "message_id" => message_id, "status_code" => status_code,
200
+ "status_text" => status_text || STATUS_TEXTS.fetch(status_code, "Status #{status_code}"),
201
+ "error_code" => error_code, "error_text" => error_text, "custom_string" => custom_string,
202
+ "subaccount_id" => Payloads::SUBACCOUNT_ID, "message_type" => "sms"
203
+ }.freeze
204
+ @lock.synchronize do
205
+ @receipts << Entry.new(payload, false)
206
+ @latest_receipts[payload["message_id"]] = payload
207
+ end
208
+ SMS::Receipt.from_api(payload)
209
+ end
210
+
211
+ # Seeds an inbound SMS (a reply), unread. With +reply_to:+ a SentMessage,
212
+ # +from+ defaults to its recipient, +to+ to its sender and the original_*
213
+ # fields and custom_string come from it.
214
+ #
215
+ # fake.add_inbound(reply_to: fake.sent_messages.last, body: "STOP")
216
+ #
217
+ # @param timestamp [Time, Integer, nil] when it arrived (default: now)
218
+ # @return [Clicksend::SMS::InboundMessage] built from the payload the API returns
219
+ def add_inbound(body:, from: nil, to: nil, original_message_id: nil, original_body: nil, custom_string: nil, timestamp: nil, reply_to: nil)
220
+ if reply_to
221
+ sent_message!(reply_to, "reply_to")
222
+ from ||= reply_to.to
223
+ to ||= reply_to.from
224
+ original_message_id ||= reply_to.message_id
225
+ original_body ||= reply_to.body
226
+ custom_string ||= reply_to.custom_string
227
+ end
228
+ raise ArgumentError, "from must be the sender's phone number (a non-empty String)" unless from.is_a?(String) && !from.empty?
229
+ raise ArgumentError, "body must be a String" unless body.is_a?(String)
230
+ strings!(to: to, original_message_id: original_message_id, original_body: original_body, custom_string: custom_string)
231
+
232
+ payload = {
233
+ "timestamp" => unix(timestamp || now, "timestamp"), "from" => from, "body" => body,
234
+ "original_body" => original_body, "original_message_id" => original_message_id, "to" => to,
235
+ "custom_string" => custom_string || "", "message_id" => SecureRandom.uuid.upcase
236
+ }.freeze
237
+ @lock.synchronize { @inbound << Entry.new(payload, false) }
238
+ SMS::InboundMessage.from_api(payload)
239
+ end
240
+
241
+ # The transport interface (see Clicksend::Transport). +headers+ are
242
+ # ignored and never stored: they hold the credentials.
243
+ # @return [Clicksend::Transport::Response]
244
+ # @raise [Clicksend::ConnectionError] for injected connection failures
245
+ def call(method, path, query: nil, body: nil, headers: nil)
246
+ request = build_request(method, path, query, body)
247
+ failure, stub = @lock.synchronize do
248
+ @requests << request
249
+ [take_failure(request), @stubs[[request.http_method, request.path]]]
250
+ end
251
+ return failure.trigger if failure && !failure.processed?
252
+
253
+ response = stub ? stubbed(stub, request) : @lock.synchronize { route(request) }
254
+ failure ? failure.trigger : response
255
+ end
256
+
257
+ def inspect
258
+ @lock.synchronize { "#<#{self.class.name} sent_messages=#{@outbox.size} requests=#{@requests.size}>" }
259
+ end
260
+ alias_method :to_s, :inspect
261
+
262
+ private
263
+
264
+ def build_request(method, path, query, body)
265
+ path, raw_query = path.to_s.split("?", 2)
266
+ params = raw_query ? URI.decode_www_form(raw_query).to_h : {}
267
+ (query || {}).each { |key, value| params[key.to_s] = value.to_s }
268
+ parsed = begin
269
+ JSON.parse(body, freeze: true) unless body.nil?
270
+ rescue JSON::ParserError
271
+ body.dup.freeze
272
+ end
273
+ Request.new(http_method: method.to_s.downcase.to_sym, path: path.freeze, query: params.to_h { |k, v| [k.freeze, v.freeze] }.freeze, body: parsed)
274
+ end
275
+
276
+ def take_failure(request)
277
+ index = @failures.index { |failure, _| failure.matches?(request) }
278
+ return unless index
279
+
280
+ slot = @failures[index]
281
+ slot[1] -= 1
282
+ @failures.delete_at(index) if slot[1].zero?
283
+ slot[0]
284
+ end
285
+
286
+ def stubbed(stub, request)
287
+ result = begin
288
+ stub.call(request)
289
+ rescue *MISTAKES => e
290
+ raise StubError, "the FakeAPI stub for #{request.http_method.upcase} #{request.path} raised #{e.class}: #{e.message}"
291
+ end
292
+ return result if result.is_a?(Transport::Response)
293
+ raise StubError, "a FakeAPI stub must return a Hash or a Clicksend::Transport::Response, got #{result.class}" unless result.is_a?(Hash)
294
+
295
+ body = result.transform_keys(&:to_s)
296
+ body = {"http_code" => 200, "response_code" => "SUCCESS", "response_msg" => "OK"}.merge(body) unless body.key?("http_code")
297
+ Payloads.respond(body["http_code"].is_a?(Integer) ? body["http_code"] : 200, body)
298
+ end
299
+
300
+ def route(request)
301
+ ROUTES.each do |verb, pattern, handler|
302
+ match = pattern.match(request.path) if verb == request.http_method
303
+ return __send__(handler, request, *match.captures) if match
304
+ end
305
+ Payloads.error(404)
306
+ end
307
+
308
+ def send_sms(request)
309
+ messages = request.body["messages"] if request.body.is_a?(Hash)
310
+ valid = messages.is_a?(Array) && !messages.empty? && messages.all? { |m|
311
+ m.is_a?(Hash) && m["body"].is_a?(String) && (m["to"].is_a?(String) ^ !m["list_id"].nil?)
312
+ }
313
+ return Payloads.error(400, "MISSING_REQUIRED_FIELDS", "Each message needs a body and either to or list_id.") unless valid
314
+
315
+ now = self.now
316
+ results = messages.map { |message| submit(message, now) }
317
+ accepted = results.select { |result| result["status"] == "SUCCESS" }
318
+ total = accepted.sum(Rational(0)) { |result| Rational(result["message_price"]) }
319
+ Payloads.ok("Messages queued for delivery.", {
320
+ "total_price" => (total.denominator == 1) ? total.to_i : total.to_f,
321
+ "total_count" => results.size, "queued_count" => accepted.size, "messages" => results,
322
+ "_currency" => Payloads.currency(@currency),
323
+ "blocked_count" => results.count { |result| result["status"] == "COUNTRY_NOT_ENABLED" }
324
+ })
325
+ end
326
+
327
+ def submit(message, now)
328
+ id = SecureRandom.uuid.upcase
329
+ rule = @rules.reverse_each.find { |to, _| to.nil? || to == message["to"] }
330
+ return Payloads.rejected(message, id, rule[1]) if rule
331
+ return Payloads.rejected(message, id, "INVALID_RECIPIENT") if message["to"] && !message["to"].match?(RECIPIENT)
332
+
333
+ payload = Payloads.accepted(message, id, now, @message_price)
334
+ @outbox << [SentMessage.new(
335
+ message_id: id, to: message["to"], from: message["from"], body: message["body"],
336
+ custom_string: message["custom_string"], list_id: message["list_id"],
337
+ scheduled_at: Integer(message["schedule"], exception: false)&.then { |t| Time.at(t).utc },
338
+ country: message["country"], sent_at: now.getutc
339
+ ), payload]
340
+ payload
341
+ end
342
+
343
+ def account(_request)
344
+ Payloads.ok("Here's your account.", {
345
+ "user_id" => Payloads::USER_ID, "username" => "test", "account_name" => "Clicksend::Testing::FakeAPI",
346
+ "balance" => @balance, "country" => "AU", "timezone" => "Australia/Melbourne",
347
+ "_currency" => Payloads.currency(@currency)
348
+ })
349
+ end
350
+
351
+ def list_receipts(request)
352
+ Payloads.page(@receipts.reject(&:read).map(&:payload), request.path, request.query, "Here are your delivery receipts.")
353
+ end
354
+
355
+ def show_receipt(_request, message_id)
356
+ receipt = latest_receipt(message_id)
357
+ receipt ? Payloads.ok("Your receipt.", receipt) : Payloads.error(404, "NOT_FOUND", "Receipt record not found.")
358
+ end
359
+
360
+ def mark_receipts_read(request)
361
+ mark_read(@receipts, request, "Receipts have been marked as read.")
362
+ end
363
+
364
+ def list_inbound(request)
365
+ Payloads.page(@inbound.reject(&:read).map(&:payload), request.path, request.query, "Here are your data.")
366
+ end
367
+
368
+ def mark_inbound_read(request)
369
+ mark_read(@inbound, request, "Inbound messages have been marked as read.")
370
+ end
371
+
372
+ def mark_inbound_message_read(_request, message_id)
373
+ entries = @inbound.select { |entry| !entry.read && entry.payload["message_id"] == message_id }
374
+ entries.each { |entry| entry.read = true }
375
+ Payloads.ok("Inbound messages have been marked as read.", entries.size)
376
+ end
377
+
378
+ def mark_read(entries, request, message)
379
+ body = request.body.nil? ? {} : request.body
380
+ cutoff = body["date_before"] if body.is_a?(Hash)
381
+ if !body.is_a?(Hash) || !(cutoff.nil? || cutoff.is_a?(Integer))
382
+ return Payloads.error(400, "BAD_REQUEST", "date_before must be a Unix timestamp.")
383
+ end
384
+
385
+ entries.each { |entry| entry.read = true if cutoff.nil? || entry.payload["timestamp"] < cutoff }
386
+ Payloads.ok(message, nil)
387
+ end
388
+
389
+ def latest_receipt(message_id)
390
+ @latest_receipts[message_id]
391
+ end
392
+
393
+ # The clock is test code: its failures are the test's, not ClickSend's.
394
+ def now
395
+ @clock.call
396
+ rescue *MISTAKES => e
397
+ raise StubError, "the FakeAPI clock raised #{e.class}: #{e.message}"
398
+ end
399
+
400
+ def message_id!(value)
401
+ return if value.is_a?(String) && value.match?(Resources::SMS::MESSAGE_ID)
402
+
403
+ raise ArgumentError, "message_id must be a ClickSend message ID such as \"31BC271B-1E0C-45F6-9E7E-97186C46BB82\", got #{value.inspect}"
404
+ end
405
+
406
+ def sent_message!(value, name)
407
+ raise ArgumentError, "#{name}: must be a Clicksend::Testing::SentMessage (from fake.sent_messages)" unless value.is_a?(SentMessage)
408
+ end
409
+
410
+ def strings!(**values)
411
+ values.each { |name, value| raise ArgumentError, "#{name} must be a String or nil" unless value.nil? || value.is_a?(String) }
412
+ end
413
+
414
+ def unix(value, name)
415
+ return value if value.is_a?(Integer)
416
+ return value.to_i if value.is_a?(Time)
417
+
418
+ raise ArgumentError, "#{name} must be a Time or Unix timestamp, got #{value.inspect}"
419
+ end
420
+ end
421
+ end
422
+ end