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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/README.md +418 -81
- data/docs/clicksend-api-notes.md +52 -16
- data/lib/clicksend/client.rb +58 -17
- data/lib/clicksend/connection.rb +203 -32
- data/lib/clicksend/errors.rb +99 -3
- data/lib/clicksend/instrumentation.rb +42 -0
- data/lib/clicksend/model.rb +9 -0
- data/lib/clicksend/page.rb +6 -3
- data/lib/clicksend/rate_limit.rb +40 -0
- data/lib/clicksend/resources/account.rb +1 -1
- data/lib/clicksend/resources/sms.rb +98 -20
- data/lib/clicksend/response.rb +35 -0
- data/lib/clicksend/retry_policy.rb +44 -27
- data/lib/clicksend/sms/history_record.rb +85 -0
- data/lib/clicksend/sms/message.rb +1 -1
- data/lib/clicksend/testing/failure.rb +81 -0
- data/lib/clicksend/testing/fake_api.rb +422 -0
- data/lib/clicksend/testing/payloads.rb +88 -0
- data/lib/clicksend/testing/records.rb +27 -0
- data/lib/clicksend/testing.rb +88 -0
- data/lib/clicksend/version.rb +1 -1
- data/lib/clicksend/webhook.rb +182 -0
- data/lib/clicksend.rb +4 -0
- metadata +10 -1
|
@@ -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.
|
|
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
|