kit-rb 0.4.0 → 0.5.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 +38 -1
- data/README.md +79 -1
- data/Rakefile +9 -0
- data/lib/kit/configuration.rb +8 -2
- data/lib/kit/connection.rb +25 -23
- data/lib/kit/instrumentation.rb +136 -0
- data/lib/kit/resources/subscribers.rb +1 -4
- data/lib/kit/resources/tags.rb +5 -15
- data/lib/kit/tag_names.rb +29 -0
- data/lib/kit/testing/factories.rb +124 -0
- data/lib/kit/testing/fixtures.json +2947 -0
- data/lib/kit/testing/fixtures.rb +96 -0
- data/lib/kit/testing/operations.rb +132 -0
- data/lib/kit/testing/stubs.rb +145 -0
- data/lib/kit/testing.rb +185 -0
- data/lib/kit/version.rb +1 -1
- data/lib/kit-rb.rb +2 -0
- data/sig/kit-rb.rbs +125 -2
- metadata +9 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set" # rubocop:disable Lint/RedundantRequireStatement -- autoloaded since Ruby 3.2, but be explicit
|
|
5
|
+
require_relative "operations"
|
|
6
|
+
|
|
7
|
+
module Kit
|
|
8
|
+
module Testing
|
|
9
|
+
# The documented example responses Kit::Testing builds from, generated by
|
|
10
|
+
# `rake testing:fixtures` from OPERATIONS and the vendored OpenAPI document
|
|
11
|
+
# (see spec/support/testing_fixtures.rb); a contract test fails when the
|
|
12
|
+
# shipped file differs from what the generator would produce.
|
|
13
|
+
module Fixtures
|
|
14
|
+
PATH = File.expand_path("fixtures.json", __dir__)
|
|
15
|
+
|
|
16
|
+
# { "subscribers_get" => { "verb" => "get", "path" => "/v4/subscribers/{id}",
|
|
17
|
+
# "key" => "subscriber", "kind" => "object" | "list" | "raw" | "none",
|
|
18
|
+
# "type" => "subscriber", "responses" => { "200" => { "subscriber" => {...} } } }, ... }
|
|
19
|
+
# `type` is the logical response type (see Testing::TYPES): the
|
|
20
|
+
# envelope key alone does not identify one — "stats" is three objects.
|
|
21
|
+
# Recursively freezes a parsed JSON tree (Hashes, Arrays, Strings).
|
|
22
|
+
# Done by hand rather than with `JSON.parse(freeze: true)`: that option
|
|
23
|
+
# arrived in json 2.7, and Ruby 3.2's bundled json (2.6) — which is what
|
|
24
|
+
# `ruby --disable-gems` loads — would silently leave the tree mutable.
|
|
25
|
+
def self.deep_freeze(value)
|
|
26
|
+
case value
|
|
27
|
+
when Hash then value.each_value { |v| deep_freeze(v) }
|
|
28
|
+
when Array then value.each { |v| deep_freeze(v) }
|
|
29
|
+
end
|
|
30
|
+
value.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deep-frozen: a caller reaching in through FIXTURES / .for must not be
|
|
34
|
+
# able to alter a documented example for every later builder call.
|
|
35
|
+
ALL = deep_freeze(JSON.parse(File.read(PATH)))
|
|
36
|
+
|
|
37
|
+
# Response type => every field any documented example of that type
|
|
38
|
+
# shows, so an override the canonical example happens to omit
|
|
39
|
+
# (tagged_at on a subscriber) is still accepted, while a typo — or a
|
|
40
|
+
# field of a different type that shares the envelope key — is not.
|
|
41
|
+
KNOWN_FIELDS = ALL.each_value.with_object(Hash.new { |h, k| h[k] = Set.new }) do |fixture, fields|
|
|
42
|
+
type = fixture["type"]
|
|
43
|
+
next unless type
|
|
44
|
+
|
|
45
|
+
fixture["responses"].each_value do |body|
|
|
46
|
+
value = body && body[fixture["key"]]
|
|
47
|
+
value = value.first if value.is_a?(Array)
|
|
48
|
+
fields[type].merge(value.keys) if value.is_a?(Hash)
|
|
49
|
+
end
|
|
50
|
+
end.transform_values(&:freeze).freeze
|
|
51
|
+
|
|
52
|
+
def self.for(operation)
|
|
53
|
+
ALL.fetch(operation.to_s) do
|
|
54
|
+
raise ArgumentError, "unknown Kit operation #{operation.inspect}; see Kit::Testing::OPERATIONS"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The response code to build: the first Kit documents, or `status` when
|
|
59
|
+
# it is one of them.
|
|
60
|
+
def self.status_for(fixture, status)
|
|
61
|
+
codes = fixture["responses"].keys
|
|
62
|
+
return codes.first if status.nil?
|
|
63
|
+
return status.to_s if codes.include?(status.to_s)
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "Kit documents #{codes.join(", ")} for this operation, not #{status}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# `overrides` (Symbol or String keys) merged over a copy of `example`
|
|
69
|
+
# (which may be frozen), rejecting a field Kit never documents for this
|
|
70
|
+
# response type. Override values are deep-copied too, so two rows given
|
|
71
|
+
# the same nested Hash stay independent and a frozen value does not
|
|
72
|
+
# leave part of the returned body frozen.
|
|
73
|
+
def self.merge_fields(type, example, overrides)
|
|
74
|
+
overrides.each_with_object(example.dup) do |(field, value), merged|
|
|
75
|
+
name = field.to_s
|
|
76
|
+
unless KNOWN_FIELDS[type].include?(name)
|
|
77
|
+
raise ArgumentError,
|
|
78
|
+
"#{name.inspect} is not a field of #{type.inspect}; known: #{KNOWN_FIELDS[type].sort.join(", ")}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
merged[name] = deep_dup(value)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# An unfrozen deep copy: what builders hand out, so specs may mutate.
|
|
86
|
+
def self.deep_dup(value)
|
|
87
|
+
case value
|
|
88
|
+
when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
|
|
89
|
+
when Array then value.map { |v| deep_dup(v) }
|
|
90
|
+
when String then value.dup
|
|
91
|
+
else value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
module Testing
|
|
5
|
+
# Every Kit v4 operation this gem drives, named `<resource>_<method>` after
|
|
6
|
+
# the client method that calls it (`client.subscribers.create` is
|
|
7
|
+
# :subscribers_create). Paths keep the OpenAPI `{placeholder}` form so the
|
|
8
|
+
# registry can be checked one-to-one against the vendored contract; the
|
|
9
|
+
# stub helpers substitute the placeholders.
|
|
10
|
+
#
|
|
11
|
+
# This is the single hand-written source: fixtures.json (example responses)
|
|
12
|
+
# is generated from it plus the OpenAPI document, and a contract test fails
|
|
13
|
+
# when either drifts from the other.
|
|
14
|
+
OPERATIONS = {
|
|
15
|
+
account_get: [:get, "/v4/account"],
|
|
16
|
+
account_colors: [:get, "/v4/account/colors"],
|
|
17
|
+
account_update_colors: [:put, "/v4/account/colors"],
|
|
18
|
+
account_creator_profile: [:get, "/v4/account/creator_profile"],
|
|
19
|
+
account_email_stats: [:get, "/v4/account/email_stats"],
|
|
20
|
+
account_growth_stats: [:get, "/v4/account/growth_stats"],
|
|
21
|
+
|
|
22
|
+
subscribers_list: [:get, "/v4/subscribers"],
|
|
23
|
+
subscribers_create: [:post, "/v4/subscribers"],
|
|
24
|
+
subscribers_filter: [:post, "/v4/subscribers/filter"],
|
|
25
|
+
subscribers_get: [:get, "/v4/subscribers/{id}"],
|
|
26
|
+
subscribers_update: [:put, "/v4/subscribers/{id}"],
|
|
27
|
+
subscribers_unsubscribe: [:post, "/v4/subscribers/{id}/unsubscribe"],
|
|
28
|
+
subscribers_tags: [:get, "/v4/subscribers/{subscriber_id}/tags"],
|
|
29
|
+
subscribers_stats: [:get, "/v4/subscribers/{subscriber_id}/stats"],
|
|
30
|
+
subscribers_set_location: [:post, "/v4/subscribers/{subscriber_id}/location"],
|
|
31
|
+
subscribers_update_location: [:patch, "/v4/subscribers/{subscriber_id}/location"],
|
|
32
|
+
subscribers_remove_location: [:delete, "/v4/subscribers/{subscriber_id}/location"],
|
|
33
|
+
|
|
34
|
+
tags_list: [:get, "/v4/tags"],
|
|
35
|
+
tags_create: [:post, "/v4/tags"],
|
|
36
|
+
tags_update: [:put, "/v4/tags/{id}"],
|
|
37
|
+
tags_subscribers: [:get, "/v4/tags/{tag_id}/subscribers"],
|
|
38
|
+
tags_tag_subscriber: [:post, "/v4/tags/{tag_id}/subscribers/{id}"],
|
|
39
|
+
tags_remove_subscriber: [:delete, "/v4/tags/{tag_id}/subscribers/{id}"],
|
|
40
|
+
tags_tag_subscriber_by_email: [:post, "/v4/tags/{tag_id}/subscribers"],
|
|
41
|
+
tags_remove_subscriber_by_email: [:delete, "/v4/tags/{tag_id}/subscribers"],
|
|
42
|
+
|
|
43
|
+
custom_fields_list: [:get, "/v4/custom_fields"],
|
|
44
|
+
custom_fields_create: [:post, "/v4/custom_fields"],
|
|
45
|
+
custom_fields_update: [:put, "/v4/custom_fields/{id}"],
|
|
46
|
+
custom_fields_delete: [:delete, "/v4/custom_fields/{id}"],
|
|
47
|
+
|
|
48
|
+
forms_list: [:get, "/v4/forms"],
|
|
49
|
+
forms_subscribers: [:get, "/v4/forms/{form_id}/subscribers"],
|
|
50
|
+
forms_add_subscriber: [:post, "/v4/forms/{form_id}/subscribers/{id}"],
|
|
51
|
+
forms_add_subscriber_by_email: [:post, "/v4/forms/{form_id}/subscribers"],
|
|
52
|
+
|
|
53
|
+
sequences_list: [:get, "/v4/sequences"],
|
|
54
|
+
sequences_create: [:post, "/v4/sequences"],
|
|
55
|
+
sequences_get: [:get, "/v4/sequences/{id}"],
|
|
56
|
+
sequences_update: [:put, "/v4/sequences/{id}"],
|
|
57
|
+
sequences_delete: [:delete, "/v4/sequences/{id}"],
|
|
58
|
+
sequences_subscribers: [:get, "/v4/sequences/{sequence_id}/subscribers"],
|
|
59
|
+
sequences_add_subscriber: [:post, "/v4/sequences/{sequence_id}/subscribers/{id}"],
|
|
60
|
+
sequences_add_subscriber_by_email: [:post, "/v4/sequences/{sequence_id}/subscribers"],
|
|
61
|
+
sequences_emails: [:get, "/v4/sequences/{sequence_id}/emails"],
|
|
62
|
+
sequences_create_email: [:post, "/v4/sequences/{sequence_id}/emails"],
|
|
63
|
+
sequences_email: [:get, "/v4/sequences/{sequence_id}/emails/{id}"],
|
|
64
|
+
sequences_update_email: [:put, "/v4/sequences/{sequence_id}/emails/{id}"],
|
|
65
|
+
sequences_delete_email: [:delete, "/v4/sequences/{sequence_id}/emails/{id}"],
|
|
66
|
+
|
|
67
|
+
broadcasts_list: [:get, "/v4/broadcasts"],
|
|
68
|
+
broadcasts_create: [:post, "/v4/broadcasts"],
|
|
69
|
+
broadcasts_get: [:get, "/v4/broadcasts/{id}"],
|
|
70
|
+
broadcasts_update: [:put, "/v4/broadcasts/{id}"],
|
|
71
|
+
broadcasts_delete: [:delete, "/v4/broadcasts/{id}"],
|
|
72
|
+
broadcasts_stats_list: [:get, "/v4/broadcasts/stats"],
|
|
73
|
+
broadcasts_stats: [:get, "/v4/broadcasts/{broadcast_id}/stats"],
|
|
74
|
+
broadcasts_clicks: [:get, "/v4/broadcasts/{broadcast_id}/clicks"],
|
|
75
|
+
|
|
76
|
+
email_templates_list: [:get, "/v4/email_templates"],
|
|
77
|
+
segments_list: [:get, "/v4/segments"],
|
|
78
|
+
|
|
79
|
+
posts_list: [:get, "/v4/posts"],
|
|
80
|
+
posts_get: [:get, "/v4/posts/{id}"],
|
|
81
|
+
|
|
82
|
+
snippets_list: [:get, "/v4/snippets"],
|
|
83
|
+
snippets_get: [:get, "/v4/snippets/{id}"],
|
|
84
|
+
snippets_create: [:post, "/v4/snippets"],
|
|
85
|
+
snippets_update: [:put, "/v4/snippets/{id}"],
|
|
86
|
+
|
|
87
|
+
purchases_list: [:get, "/v4/purchases"],
|
|
88
|
+
purchases_get: [:get, "/v4/purchases/{id}"],
|
|
89
|
+
purchases_create: [:post, "/v4/purchases"],
|
|
90
|
+
|
|
91
|
+
webhooks_list: [:get, "/v4/webhooks"],
|
|
92
|
+
webhooks_create: [:post, "/v4/webhooks"],
|
|
93
|
+
webhooks_delete: [:delete, "/v4/webhooks/{id}"],
|
|
94
|
+
|
|
95
|
+
webhook_endpoints_list: [:get, "/v4/webhook_endpoints"],
|
|
96
|
+
webhook_endpoints_get: [:get, "/v4/webhook_endpoints/{id}"],
|
|
97
|
+
webhook_endpoints_create: [:post, "/v4/webhook_endpoints"],
|
|
98
|
+
webhook_endpoints_update: [:patch, "/v4/webhook_endpoints/{id}"],
|
|
99
|
+
webhook_endpoints_delete: [:delete, "/v4/webhook_endpoints/{id}"],
|
|
100
|
+
webhook_endpoints_rotate_secret: [:post, "/v4/webhook_endpoints/{id}/rotate_secret"],
|
|
101
|
+
webhook_endpoints_revoke_previous_secret: [:post, "/v4/webhook_endpoints/{id}/revoke_previous_secret"],
|
|
102
|
+
|
|
103
|
+
bulk_create_subscribers: [:post, "/v4/bulk/subscribers"],
|
|
104
|
+
bulk_create_custom_fields: [:post, "/v4/bulk/custom_fields"],
|
|
105
|
+
bulk_update_custom_field_values: [:post, "/v4/bulk/custom_fields/subscribers"],
|
|
106
|
+
bulk_add_subscribers_to_forms: [:post, "/v4/bulk/forms/subscribers"],
|
|
107
|
+
bulk_create_tags: [:post, "/v4/bulk/tags"],
|
|
108
|
+
bulk_delete_tags: [:delete, "/v4/bulk/tags"],
|
|
109
|
+
bulk_tag_subscribers: [:post, "/v4/bulk/tags/subscribers"],
|
|
110
|
+
bulk_remove_tag_subscribers: [:delete, "/v4/bulk/tags/subscribers"]
|
|
111
|
+
}.each_value(&:freeze).freeze
|
|
112
|
+
|
|
113
|
+
# The logical response type an operation's envelope holds — the same
|
|
114
|
+
# grouping as the Kit::Objects class the resource builds — for the
|
|
115
|
+
# operations whose envelope key alone does not say: "stats" is three
|
|
116
|
+
# different objects, "broadcast"/"broadcasts" hold BroadcastStats on the
|
|
117
|
+
# stats endpoints, "email(s)" are sequence emails. Every other operation's
|
|
118
|
+
# type is its envelope key singularised (subscribers => subscriber).
|
|
119
|
+
TYPES = {
|
|
120
|
+
account_email_stats: "email_stats",
|
|
121
|
+
account_growth_stats: "growth_stats",
|
|
122
|
+
subscribers_stats: "subscriber_stats",
|
|
123
|
+
broadcasts_stats: "broadcast_stats",
|
|
124
|
+
broadcasts_stats_list: "broadcast_stats",
|
|
125
|
+
sequences_emails: "sequence_email",
|
|
126
|
+
sequences_create_email: "sequence_email",
|
|
127
|
+
sequences_email: "sequence_email",
|
|
128
|
+
sequences_update_email: "sequence_email",
|
|
129
|
+
account_creator_profile: "creator_profile"
|
|
130
|
+
}.freeze
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module Kit
|
|
7
|
+
module Testing # rubocop:disable Style/Documentation -- documented in lib/kit/testing.rb
|
|
8
|
+
# WebMock stubs by operation name, so a consumer spec reads as "the upsert
|
|
9
|
+
# succeeds" instead of as a URL, a header hash and a hand-written envelope:
|
|
10
|
+
#
|
|
11
|
+
# Kit::Testing.stub(:subscribers_create, id: 500) # 200 with the documented body
|
|
12
|
+
# Kit::Testing.stub(:tags_tag_subscriber, tag_id: 7, id: 500) # path params by name
|
|
13
|
+
# Kit::Testing.stub(:tags_list, items: [{ name: "vip" }], has_next_page: true)
|
|
14
|
+
# Kit::Testing.stub_error(:account_get, 401, "The API key is invalid")
|
|
15
|
+
# Kit::Testing.stub_rate_limited(:subscribers_create, retry_after: 7)
|
|
16
|
+
#
|
|
17
|
+
# Each returns WebMock's request stub, so `.with(...)`, `.to_return(...)`
|
|
18
|
+
# chaining and `expect(stub).to have_been_requested` work as usual. Path
|
|
19
|
+
# params not given match any value; any query string matches. WebMock is
|
|
20
|
+
# not a dependency of this gem: require it in your spec_helper.
|
|
21
|
+
module Stubs
|
|
22
|
+
BASE_URL = "https://api.kit.com"
|
|
23
|
+
JSON_HEADERS = { "Content-Type" => "application/json" }.freeze
|
|
24
|
+
|
|
25
|
+
# A successful response for `operation`: the documented example at
|
|
26
|
+
# `http_status:` (default: the first Kit documents) with `overrides` on
|
|
27
|
+
# its envelope object. Path params are given by name (`id:`, `tag_id:`);
|
|
28
|
+
# for a list operation `items:` and the pagination keywords shape the
|
|
29
|
+
# page as #list_json does. A path param that is also a field of the
|
|
30
|
+
# response object (`id:` on subscribers_get) applies to both, so the
|
|
31
|
+
# body answers with the id that was asked for.
|
|
32
|
+
def stub(operation, http_status: nil, **params)
|
|
33
|
+
fixture = Fixtures.for(operation)
|
|
34
|
+
template = operation(operation).last
|
|
35
|
+
path_params = params.select { |key, _| template.include?("{#{key}}") }
|
|
36
|
+
overrides = params.reject { |key, _| path_params.key?(key) && !body_field?(fixture, key) }
|
|
37
|
+
status = Fixtures.status_for(fixture, http_status)
|
|
38
|
+
request_stub(operation, path_params).to_return(status: status.to_i, headers: JSON_HEADERS,
|
|
39
|
+
body: stub_body(operation, fixture, status, overrides))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A failing response: Kit's { "errors" => [...] } envelope at `status`.
|
|
43
|
+
def stub_error(operation, status, *messages, **path_params)
|
|
44
|
+
request_stub(operation, path_params)
|
|
45
|
+
.to_return(status: status, headers: JSON_HEADERS, body: JSON.generate(error_json(*messages)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A 429 with `Retry-After` (seconds), as Kit sends it.
|
|
49
|
+
def stub_rate_limited(operation, retry_after: 30, **path_params)
|
|
50
|
+
request_stub(operation, path_params)
|
|
51
|
+
.to_return(status: 429, headers: JSON_HEADERS.merge("Retry-After" => retry_after.to_s),
|
|
52
|
+
body: JSON.generate(error_json("Rate limit exceeded")))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The URL of an operation with its path params filled in: a String when
|
|
56
|
+
# every placeholder is given (for `a_request(verb, url)`), else a Regexp
|
|
57
|
+
# with `[^/?]+` for the rest. Neither admits a query string; stubs do.
|
|
58
|
+
def url_for(operation, **path_params)
|
|
59
|
+
template, given = url_parts(operation, path_params)
|
|
60
|
+
return "#{BASE_URL}#{fill(template, given)}" if complete?(template, given)
|
|
61
|
+
|
|
62
|
+
/\A#{Regexp.escape(BASE_URL)}#{path_pattern(template, given)}\z/
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# The template and the given params as encoded path segments — exactly
|
|
68
|
+
# what Resources::Base#path_id sends, so `id: "a b"` matches `/a%20b` and
|
|
69
|
+
# `id: "1/unsubscribe"` cannot rewrite the route; nil/blank raises like
|
|
70
|
+
# the client does instead of collapsing onto the parent route.
|
|
71
|
+
def url_parts(operation, path_params)
|
|
72
|
+
template = operation(operation).last
|
|
73
|
+
unknown = path_params.keys.map(&:to_s) - placeholders(template)
|
|
74
|
+
raise ArgumentError, "#{unknown.first.inspect} is not a path param of #{template}" if unknown.any?
|
|
75
|
+
|
|
76
|
+
[template, path_params.to_h { |key, value| [key.to_s, path_segment(key, value)] }]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def path_segment(key, value)
|
|
80
|
+
raise ArgumentError, "#{key} must not be nil or blank" if value.nil? || value.to_s.strip.empty?
|
|
81
|
+
|
|
82
|
+
ERB::Util.url_encode(value.to_s)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def placeholders(template) = template.scan(/\{(\w+)\}/).flatten
|
|
86
|
+
def complete?(template, given) = (placeholders(template) - given.keys).empty?
|
|
87
|
+
def fill(template, given) = template.gsub(/\{(\w+)\}/) { given.fetch(Regexp.last_match(1)) }
|
|
88
|
+
|
|
89
|
+
def path_pattern(template, given)
|
|
90
|
+
Regexp.escape(template).gsub(/\\\{(\w+)\\\}/) do
|
|
91
|
+
given.key?(Regexp.last_match(1)) ? Regexp.escape(given[Regexp.last_match(1)]) : "[^/?]+"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Stubs match the path with any query string, since list filters and
|
|
96
|
+
# remove_*_by_email travel there.
|
|
97
|
+
def request_stub(operation, path_params)
|
|
98
|
+
webmock!
|
|
99
|
+
verb, = operation(operation)
|
|
100
|
+
template, given = url_parts(operation, path_params)
|
|
101
|
+
WebMock::API.stub_request(verb, /\A#{Regexp.escape(BASE_URL)}#{path_pattern(template, given)}(\?.*)?\z/)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# True when `key` is a documented field of the operation's response type
|
|
105
|
+
# (so a same-named path param should also override the body).
|
|
106
|
+
def body_field?(fixture, key)
|
|
107
|
+
type = fixture["type"]
|
|
108
|
+
type && Fixtures::KNOWN_FIELDS[type]&.include?(key.to_s)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The body for the selected status: the list page (list operations
|
|
112
|
+
# document one status), the status-specific example (bulk 202s are
|
|
113
|
+
# empty; a 201 may differ from the 200), or nothing for a 204.
|
|
114
|
+
def stub_body(operation, fixture, status, overrides)
|
|
115
|
+
case fixture["kind"]
|
|
116
|
+
when "none" then ""
|
|
117
|
+
when "list" then JSON.generate(list_body(operation, overrides))
|
|
118
|
+
else JSON.generate(response(operation, http_status: status, **overrides))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A list page: `items:` (rows or a count), the pagination keywords, and
|
|
123
|
+
# any remaining keys — a same-named path field such as sequence_id — are
|
|
124
|
+
# applied to every row.
|
|
125
|
+
def list_body(operation, overrides)
|
|
126
|
+
items = overrides.fetch(:items, 1)
|
|
127
|
+
pagination = overrides.slice(*PAGINATION_FIELDS)
|
|
128
|
+
row_fields = overrides.except(:items, *PAGINATION_FIELDS)
|
|
129
|
+
rows = items.is_a?(Integer) ? Array.new(items) { |i| { "id" => attributes(operation)["id"].to_i + i } } : items
|
|
130
|
+
# The path is the source of truth for a shared field: it wins over a row.
|
|
131
|
+
rows = rows.map { |row| row.merge(row_fields) } if row_fields.any?
|
|
132
|
+
list_json(operation, rows, **pagination)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def webmock!
|
|
136
|
+
return if defined?(WebMock::API)
|
|
137
|
+
|
|
138
|
+
raise ConfigurationError,
|
|
139
|
+
"Kit::Testing.stub needs WebMock: add `gem \"webmock\"` to your test group and require \"webmock/rspec\""
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
extend Stubs
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/kit/testing.rb
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "testing/operations"
|
|
4
|
+
require_relative "testing/fixtures"
|
|
5
|
+
|
|
6
|
+
module Kit
|
|
7
|
+
# Test support for consumers of this gem: `require "kit/testing"` (it is not
|
|
8
|
+
# loaded by `require "kit-rb"`, and depends on no test framework).
|
|
9
|
+
#
|
|
10
|
+
# Every builder starts from Kit's own documented example response for the
|
|
11
|
+
# operation (lib/kit/testing/fixtures.json, generated from the OpenAPI
|
|
12
|
+
# document and kept in step by a contract test), so a consumer spec reads as
|
|
13
|
+
# "the upsert succeeds" instead of a hand-written envelope that drifts when
|
|
14
|
+
# Kit changes a field:
|
|
15
|
+
#
|
|
16
|
+
# Kit::Testing.subscriber_json(id: 500, email_address: "ada@example.com")
|
|
17
|
+
# # => { "subscriber" => { "id" => 500, "email_address" => "ada@example.com", "state" => "active", ... } }
|
|
18
|
+
#
|
|
19
|
+
# Kit::Testing.response(:tags_create, http_status: 201, name: "vip") # any operation, by name
|
|
20
|
+
# Kit::Testing.list_json(:tags_list, [{ name: "vip" }, { name: "beta" }], has_next_page: true)
|
|
21
|
+
# Kit::Testing.error_json("The API key is invalid") # => { "errors" => [...] }
|
|
22
|
+
#
|
|
23
|
+
# Kit::Testing.subscriber(id: 500) # typed Kit::Objects::Subscriber
|
|
24
|
+
# Kit::Testing.account_info(plan_type: "free") # Kit::Objects::AccountInfo
|
|
25
|
+
# Kit::Testing.oauth_token(created_at: Time.now.to_i) # Kit::OAuth::Token
|
|
26
|
+
#
|
|
27
|
+
# Kit::Testing.stub(:subscribers_create, id: 500) # WebMock stub by operation
|
|
28
|
+
# Kit::Testing.stub_error(:account_get, 401, "The API key is invalid")
|
|
29
|
+
#
|
|
30
|
+
# Operation names are `<resource>_<method>` after the client method
|
|
31
|
+
# (Kit::Testing::OPERATIONS). Overrides must be fields Kit documents for
|
|
32
|
+
# that response type (Kit::Testing::TYPES) — a typo, or a field of another
|
|
33
|
+
# type that happens to share the envelope key, raises ArgumentError rather
|
|
34
|
+
# than silently building a response the real API would never send.
|
|
35
|
+
module Testing
|
|
36
|
+
FIXTURES_PATH = Fixtures::PATH
|
|
37
|
+
FIXTURES = Fixtures::ALL
|
|
38
|
+
KNOWN_FIELDS = Fixtures::KNOWN_FIELDS
|
|
39
|
+
|
|
40
|
+
# The canonical operation behind each `<object>_json` / `<object>` builder.
|
|
41
|
+
OBJECTS = {
|
|
42
|
+
subscriber: :subscribers_get,
|
|
43
|
+
tag: :tags_create,
|
|
44
|
+
custom_field: :custom_fields_create,
|
|
45
|
+
sequence: :sequences_get,
|
|
46
|
+
sequence_email: :sequences_email,
|
|
47
|
+
broadcast: :broadcasts_get,
|
|
48
|
+
broadcast_stats: :broadcasts_stats,
|
|
49
|
+
post: :posts_get,
|
|
50
|
+
snippet: :snippets_get,
|
|
51
|
+
purchase: :purchases_get,
|
|
52
|
+
webhook: :webhooks_create,
|
|
53
|
+
webhook_endpoint: :webhook_endpoints_get,
|
|
54
|
+
creator_profile: :account_creator_profile,
|
|
55
|
+
email_stats: :account_email_stats,
|
|
56
|
+
growth_stats: :account_growth_stats,
|
|
57
|
+
subscriber_stats: :subscribers_stats,
|
|
58
|
+
account: :account_get
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
PAGINATION_FIELDS = %i[has_next_page has_previous_page start_cursor end_cursor per_page total_count].freeze
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
# The documented example body for `operation` at `http_status` (default:
|
|
65
|
+
# the first 2xx Kit documents), with `overrides` applied to its envelope
|
|
66
|
+
# object. For a paginated list the overrides apply to the first item;
|
|
67
|
+
# use #list_json to shape the whole page. (The selector is not called
|
|
68
|
+
# `status:` because `status` is a documented field of posts, purchases,
|
|
69
|
+
# broadcasts and webhook endpoints, which must stay overridable.)
|
|
70
|
+
def response(operation, http_status: nil, **overrides)
|
|
71
|
+
fixture = Fixtures.for(operation)
|
|
72
|
+
body = Fixtures.deep_dup(fixture["responses"].fetch(Fixtures.status_for(fixture, http_status)))
|
|
73
|
+
apply_overrides!(fixture, body, overrides) unless overrides.empty?
|
|
74
|
+
body
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The envelope object alone (no wrapper key): what `klass.from` receives.
|
|
78
|
+
def attributes(operation, **overrides)
|
|
79
|
+
fixture = Fixtures.for(operation)
|
|
80
|
+
raise ArgumentError, "#{operation} has no envelope object to build" unless %w[object
|
|
81
|
+
list].include?(fixture["kind"])
|
|
82
|
+
|
|
83
|
+
value = response(operation, **overrides).fetch(fixture["key"])
|
|
84
|
+
fixture["kind"] == "list" ? value.first : value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A cursor-paginated page for a list operation. `items` is an Array of
|
|
88
|
+
# override Hashes (each merged over the documented example item) or an
|
|
89
|
+
# Integer count of example items; pagination fields are keywords.
|
|
90
|
+
def list_json(operation, items = 1, **pagination)
|
|
91
|
+
fixture = Fixtures.for(operation)
|
|
92
|
+
raise ArgumentError, "#{operation} is not a paginated list" unless fixture["kind"] == "list"
|
|
93
|
+
|
|
94
|
+
body = Fixtures.deep_dup(fixture["responses"].values.first)
|
|
95
|
+
rows = list_rows(fixture["type"], attributes(operation), items)
|
|
96
|
+
body[fixture["key"]] = rows
|
|
97
|
+
body["pagination"] = pagination_json(body["pagination"], **page_fields(rows, pagination))
|
|
98
|
+
body
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A pagination object: `example` (default: the tags list's) with exactly
|
|
102
|
+
# the given fields replaced — an omitted keyword keeps the example's
|
|
103
|
+
# value, an explicit nil clears a cursor. list_json passes the terminal-
|
|
104
|
+
# page defaults itself.
|
|
105
|
+
def pagination_json(example = Fixtures.for(:tags_list)["responses"]["200"]["pagination"], **fields)
|
|
106
|
+
unknown = fields.keys - PAGINATION_FIELDS
|
|
107
|
+
if unknown.any?
|
|
108
|
+
raise ArgumentError, "unknown pagination field(s) #{unknown.inspect}; known: #{PAGINATION_FIELDS.join(", ")}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
page = example.dup
|
|
112
|
+
fields.each { |field, value| page[field.to_s] = value }
|
|
113
|
+
Fixtures.deep_dup(page) # neither the frozen example's strings nor the caller's own values
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The error envelope Kit sends on every non-2xx: { "errors" => [...] }.
|
|
117
|
+
def error_json(*messages) = { "errors" => Fixtures.deep_dup(messages.flatten) }
|
|
118
|
+
|
|
119
|
+
# The HTTP status a builder defaults to for `operation` (or validates).
|
|
120
|
+
def http_status(operation, http_status = nil) = Fixtures.status_for(Fixtures.for(operation), http_status).to_i
|
|
121
|
+
|
|
122
|
+
# The [verb, path] Kit::Testing::OPERATIONS declares for `operation`
|
|
123
|
+
# (a frozen tuple; the registry cannot be altered through it).
|
|
124
|
+
def operation(name)
|
|
125
|
+
Fixtures.for(name) # validates the name with the same message
|
|
126
|
+
OPERATIONS.fetch(name.to_sym)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# list_json's page is a single terminal page unless told otherwise, and an
|
|
132
|
+
# empty page has no cursors, as the real API answers; keywords still win.
|
|
133
|
+
def page_fields(rows, pagination)
|
|
134
|
+
defaults = { has_next_page: false, has_previous_page: false }
|
|
135
|
+
defaults.merge!(start_cursor: nil, end_cursor: nil) if rows.empty?
|
|
136
|
+
defaults.merge(pagination)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Rows for a page: n deep copies of the example with distinct ids (kept
|
|
140
|
+
# in the example's own type — subscribers_filter documents string ids),
|
|
141
|
+
# or each given override Hash merged over its own deep copy, so mutating
|
|
142
|
+
# one row's nested Hash never changes a sibling.
|
|
143
|
+
def list_rows(type, example, items)
|
|
144
|
+
unless items.is_a?(Integer)
|
|
145
|
+
return items.map do |row|
|
|
146
|
+
Fixtures.merge_fields(type, Fixtures.deep_dup(example), row)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
Array.new(items) do |i|
|
|
151
|
+
row = Fixtures.deep_dup(example)
|
|
152
|
+
row["id"] = successor_id(example["id"], i) if example.key?("id")
|
|
153
|
+
row
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def successor_id(id, offset)
|
|
158
|
+
case id
|
|
159
|
+
when Integer then id + offset
|
|
160
|
+
when String then (id.to_i + offset).to_s
|
|
161
|
+
else id
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def apply_overrides!(fixture, body, overrides)
|
|
166
|
+
key = fixture["key"]
|
|
167
|
+
type = fixture["type"]
|
|
168
|
+
case fixture["kind"]
|
|
169
|
+
when "object" then body[key] = Fixtures.merge_fields(type, body[key], overrides)
|
|
170
|
+
when "list" then body[key][0] = Fixtures.merge_fields(type, body[key][0], overrides)
|
|
171
|
+
else raise ArgumentError, "#{fixture["verb"].upcase} #{fixture["path"]} has no envelope object to override"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Kit::Testing.subscriber_json(id: 1) etc., one per OBJECTS entry: the
|
|
177
|
+
# canonical operation's response with overrides on its envelope object.
|
|
178
|
+
OBJECTS.each do |object, operation|
|
|
179
|
+
define_singleton_method(:"#{object}_json") { |**overrides| response(operation, **overrides) }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
require_relative "testing/factories"
|
|
185
|
+
require_relative "testing/stubs"
|
data/lib/kit/version.rb
CHANGED
data/lib/kit-rb.rb
CHANGED
|
@@ -14,10 +14,12 @@ end
|
|
|
14
14
|
|
|
15
15
|
require "kit/version"
|
|
16
16
|
require "kit/errors"
|
|
17
|
+
require "kit/tag_names"
|
|
17
18
|
require "kit/configuration"
|
|
18
19
|
require "kit/auth/credential"
|
|
19
20
|
require "kit/auth/api_key"
|
|
20
21
|
require "kit/auth/oauth"
|
|
22
|
+
require "kit/instrumentation"
|
|
21
23
|
require "kit/connection"
|
|
22
24
|
require "kit/pagination"
|
|
23
25
|
require "kit/oauth/pkce"
|