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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cae15a3c1ed91dc15b4e8e6046c4b00c851f0abd3a0bba1e3e65f0c75dec5b5e
4
- data.tar.gz: e9f0b1a56b24687b3e6bb0fdc30d1f6d3f20f3b7679b9ffa6ebffdd17f5569ad
3
+ metadata.gz: d8a032593d0a6e493c45d6d0bb84bd540b0dc7e61fd596ed1b4048b1b7d44d66
4
+ data.tar.gz: 2e0616764a8ca5b0f9fb64114dd5d2d0c2c5141fa2a5e057c1a4d39d423a8e90
5
5
  SHA512:
6
- metadata.gz: a535e23e3f337c84e57bd89833486efc32f95231f3f9a5a5804ecf6bae99c009c9780d3120e6de9062c4ef289871a8971f214d98d923767fe0c5324e1de4f372
7
- data.tar.gz: 39638ca088dc6f40886566d4515635b83b713597f3d8e480da9444d60cd8c19df1449aceed45a04ba53e94544de2f7728e2c0395687f5be170dd27579dcd6133
6
+ metadata.gz: 8de55860a7da6d89e4488d94999e106d291bcfde45e3523b740d25b1861b78b7eccea1de82280b80b22288d3cdb7447185dc5b94f45add55a44447bc5f998bba
7
+ data.tar.gz: 5b80309f71b11d1c74ec5b7d1332c910d10fab5a7690608bb78f0dfa564247d148c1a0a67e78e8fcf2b2adad889248157958dbf07116f445ed3619ef11287151
data/CHANGELOG.md CHANGED
@@ -6,6 +6,42 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-09-09
10
+
11
+ ### Added
12
+ - `require "kit/testing"`: envelope builders for consumer specs, built from
13
+ Kit's documented example responses. `Kit::Testing.response(operation,
14
+ http_status:, **overrides)` for any of the 83 operations (`Kit::Testing::OPERATIONS`,
15
+ named `<resource>_<method>`), `attributes` (the bare object), `list_json`
16
+ (a cursor page from rows or a count), `pagination_json`, `error_json`, and
17
+ one `<object>_json` per envelope (`subscriber_json`, `tag_json`,
18
+ `account_json`, ...). Overrides must be fields Kit documents for that
19
+ envelope. Fixtures are generated by `rake testing:fixtures` from the vendored
20
+ OpenAPI document and pinned by a contract test. Not loaded by `kit-rb`. (#14)
21
+ - `Kit::Testing` typed factories: one `<object>` per envelope builder
22
+ (`Kit::Testing.subscriber(id: 1)` → `Kit::Objects::Subscriber`, `tag`,
23
+ `account`, ...), parsed by the same `Kit::Objects` class the resource uses,
24
+ plus `account_info` (the whole GET /v4/account result), `oauth_token`
25
+ (`Kit::OAuth::Token` with a working 48 h expiry) and `tagged_subscriber`
26
+ (`upsert_and_tag`'s result). Overrides are validated like the builders'.
27
+ Loading `kit/testing` no longer needs http.rb. (#14)
28
+ - `Kit::Testing.stub(operation, http_status:, **path_params_and_overrides)`,
29
+ `stub_error(operation, status, *messages)`, `stub_rate_limited(operation,
30
+ retry_after:)` and `url_for(operation, **path_params)`: WebMock stubs by
31
+ operation name, built from the same documented examples — path params by
32
+ name, percent-encoded exactly as the client sends them (one that is also a
33
+ response field, like `id:`, shapes the body too; on a list, every row),
34
+ unspecified ones and any query string match, `http_status:` selects the
35
+ status-specific documented body, 204 operations answer with no body. WebMock is required by the consumer, not by this gem; a clear
36
+ `ConfigurationError` says so when it is absent. (#14)
37
+ - `instrumenter:` and `logger:` on `Kit::Client` / `Kit::Configuration`. The
38
+ instrumenter receives one `"request.kit"` event per HTTP attempt through
39
+ `instrument(name, payload) { }` (`ActiveSupport::Notifications` compatible,
40
+ no Rails dependency) with `method`, `path`, `status`, `duration`, `retries`,
41
+ `retry_after` and `error`; never the query string, body or a header value.
42
+ `logger:` writes the same payload as one debug line per attempt. Both may be
43
+ given. (#13)
44
+
9
45
  ## [0.4.0] - 2026-09-09
10
46
 
11
47
  ### Added
@@ -199,7 +235,8 @@ vertical slice. Not yet feature-complete — resources land in 0.1.0 per docs/DE
199
235
  429/5xx retry with backoff.
200
236
  - RBS signatures for the public surface.
201
237
 
202
- [Unreleased]: https://github.com/linyiru/kit-rb/compare/v0.4.0...HEAD
238
+ [Unreleased]: https://github.com/linyiru/kit-rb/compare/v0.5.0...HEAD
239
+ [0.5.0]: https://github.com/linyiru/kit-rb/compare/v0.4.0...v0.5.0
203
240
  [0.4.0]: https://github.com/linyiru/kit-rb/compare/v0.3.1...v0.4.0
204
241
  [0.3.1]: https://github.com/linyiru/kit-rb/compare/v0.3.0...v0.3.1
205
242
  [0.3.0]: https://github.com/linyiru/kit-rb/compare/v0.2.0...v0.3.0
data/README.md CHANGED
@@ -75,6 +75,33 @@ A 429 is retried for every request (with `Retry-After`, capped at
75
75
  retried only for idempotent verbs, so a POST is never replayed after those.
76
76
  Credentials are masked in `#inspect`.
77
77
 
78
+ ### Instrumentation
79
+
80
+ Pass `instrumenter:` to observe every HTTP attempt (Sentry breadcrumbs,
81
+ per-request timing), or `logger:` for one debug line each:
82
+
83
+ ```ruby
84
+ client = Kit::Client.new(api_key: key, instrumenter: ActiveSupport::Notifications)
85
+ ActiveSupport::Notifications.subscribe("request.kit") do |*, payload|
86
+ Sentry.add_breadcrumb(Sentry::Breadcrumb.new(category: "kit", data: payload))
87
+ end
88
+
89
+ client = Kit::Client.new(api_key: key, logger: Rails.logger)
90
+ # D, kit GET /v4/account status=429 duration=0.212s retries=0 retry_after=7 error=Kit::RateLimitError
91
+ # D, kit GET /v4/account status=200 duration=0.180s retries=1
92
+ ```
93
+
94
+ The interface is `ActiveSupport::Notifications`' (`instrument(name, payload) { }`)
95
+ but the gem has no Rails dependency: any object with that method works. One
96
+ `"request.kit"` event is emitted per attempt, so a retried request shows each
97
+ attempt, with `method`, `path`, `status` (nil when no response arrived),
98
+ `duration` (seconds), `retries`, `retry_after` (on a 429) and `error` (the Kit
99
+ error class name). The payload never contains the query string, the body, or
100
+ any header value, so no credential or subscriber email can reach a log — and a
101
+ failed attempt is reported through `error`, not by raising inside the
102
+ instrumenter, so `ActiveSupport::Notifications` never attaches the exception
103
+ (with its response body) to the payload.
104
+
78
105
  ### Background jobs
79
106
 
80
107
  The built-in retry sleeps on the calling thread, which is right for a script
@@ -284,7 +311,58 @@ produces. A transport error means no response was received, not that the request
284
311
  was not processed: a timed-out `refresh` may already have consumed the refresh
285
312
  token (documented as single-use), so treat retrying it as your own decision.
286
313
 
287
- ## Testing
314
+ ## Testing your integration
315
+
316
+ `require "kit/testing"` (not loaded by `kit-rb` itself; no test-framework
317
+ dependency) builds response bodies from Kit's own documented examples, so a
318
+ consumer spec reads as "the upsert succeeds" instead of a hand-written envelope
319
+ that drifts when a field changes:
320
+
321
+ ```ruby
322
+ require "kit/testing"
323
+
324
+ Kit::Testing.subscriber_json(id: 500, email_address: "ada@example.com")
325
+ # => { "subscriber" => { "id" => 500, "email_address" => "ada@example.com", "state" => "active", ... } }
326
+ Kit::Testing.tag_json(name: "vip") # { "tag" => {...} }
327
+ Kit::Testing.account_json(plan_type: "free", name: "") # { "user" => {...}, "account" => {...} }
328
+ Kit::Testing.error_json("The API key is invalid") # { "errors" => [...] }
329
+
330
+ Kit::Testing.response(:tags_create, http_status: 201, name: "vip") # any operation, by name
331
+ Kit::Testing.list_json(:subscribers_list, [{ id: 1 }, { id: 2 }], has_next_page: true, end_cursor: "E")
332
+ Kit::Testing.attributes(:subscribers_get, first_name: "Ada") # the bare object, for Objects::Subscriber.from
333
+
334
+ # With WebMock loaded, stub a whole operation by name (path params by name — one
335
+ # that is also a response field, like id:, shapes the body too; any query string
336
+ # matches; returns WebMock's stub for .with / have_been_requested):
337
+ Kit::Testing.stub(:subscribers_create, id: 500)
338
+ Kit::Testing.stub(:tags_tag_subscriber, tag_id: 7, id: 500)
339
+ Kit::Testing.stub(:tags_list, items: [{ name: "vip" }], has_next_page: true)
340
+ Kit::Testing.stub_error(:account_get, 401, "The API key is invalid")
341
+ Kit::Testing.stub_rate_limited(:subscribers_create, retry_after: 7)
342
+ expect(a_request(:post, Kit::Testing.url_for(:tags_tag_subscriber, tag_id: 7, id: 500))).to have_been_made
343
+
344
+ # Typed values for instance_double returns and unit tests — the same examples,
345
+ # parsed by the same Kit::Objects classes the client uses:
346
+ Kit::Testing.subscriber(id: 500, email_address: "ada@example.com") # => Kit::Objects::Subscriber
347
+ Kit::Testing.account_info(plan_type: "free", user: { email: "owner@example.com" })
348
+ Kit::Testing.oauth_token(created_at: Time.now.to_i) # => Kit::OAuth::Token, 48 h expiry
349
+ Kit::Testing.tagged_subscriber(tag_names: ["vip", "beta"]) # upsert_and_tag's result
350
+ allow(kit).to receive(:subscribers).and_return(instance_double(Kit::Resources::Subscribers, get: Kit::Testing.subscriber))
351
+ ```
352
+
353
+ Operations are named `<resource>_<method>` after the client method
354
+ (`Kit::Testing::OPERATIONS`, 83 of them); `http_status:` picks among the 2xx
355
+ codes Kit documents for one (`status:` stays free for the field of that name on
356
+ posts, purchases, broadcasts and webhook endpoints). An override that is not a documented field of the
357
+ response type (`Kit::Testing::TYPES` disambiguates envelopes such as `"stats"`
358
+ that wrap different objects) raises `ArgumentError`, so a typo — or a field of
359
+ the wrong object — cannot build a response the real API would never send. The fixtures are generated from the vendored OpenAPI
360
+ document's examples (`rake testing:fixtures`) and a contract test fails when
361
+ they, the operation registry, or the document drift apart. `require "kit/testing"` loads only the
362
+ value objects, not http.rb, so it works wherever the gem's runtime dependencies
363
+ are absent.
364
+
365
+ ## Testing this gem
288
366
 
289
367
  The suite is layered:
290
368
 
data/Rakefile CHANGED
@@ -25,6 +25,15 @@ namespace :contract do
25
25
  end
26
26
  end
27
27
 
28
+ namespace :testing do
29
+ desc "Regenerate lib/kit/testing/fixtures.json from the registry + OpenAPI examples"
30
+ task :fixtures do
31
+ require_relative "spec/support/testing_fixtures"
32
+ TestingFixtures.write
33
+ puts "Wrote #{TestingFixtures::PATH} (#{Kit::Testing::OPERATIONS.size} operations)"
34
+ end
35
+ end
36
+
28
37
  desc "Live read-only smoke test against the real API (needs KIT_API_KEY)"
29
38
  task :smoke do
30
39
  require_relative "spec/support/smoke"
@@ -12,15 +12,21 @@ module Kit
12
12
  # differs from the token it was given, another process refreshed first and
13
13
  # it should return the persisted token without calling Kit. Errors it raises
14
14
  # propagate untouched. A 403 is never renewed: that is scope, not expiry.
15
+ #
16
+ # `instrumenter:` receives one "request.kit" event per HTTP attempt through
17
+ # `instrument(name, payload) { }` (ActiveSupport::Notifications compatible);
18
+ # `logger:` logs the same payload at debug level. See Kit::Instrumentation.
15
19
  class Configuration
16
20
  attr_reader :auth, :base_url, :open_timeout, :read_timeout, :write_timeout,
17
- :max_retries, :retry_backoff, :max_backoff, :renew
21
+ :max_retries, :retry_backoff, :max_backoff, :renew, :instrumenter
18
22
 
19
23
  def initialize(api_key: nil, access_token: nil, base_url: DEFAULT_BASE_URL,
20
24
  open_timeout: 10, read_timeout: 30, write_timeout: 30,
21
- max_retries: 2, retry_backoff: 0.5, max_backoff: 30, renew: nil)
25
+ max_retries: 2, retry_backoff: 0.5, max_backoff: 30, renew: nil,
26
+ instrumenter: nil, logger: nil)
22
27
  @auth = build_auth(api_key, access_token)
23
28
  @renew = validate_renew(renew)
29
+ @instrumenter = Instrumentation.build(instrumenter: instrumenter, logger: logger)
24
30
  @base_url = base_url
25
31
  @open_timeout = open_timeout
26
32
  @read_timeout = read_timeout
@@ -56,8 +56,10 @@ module Kit
56
56
  attempt = 0
57
57
  renewed = false
58
58
  begin
59
- used = renewable_token
60
- handle(perform(method, path, params, body, used), method, path)
59
+ # Snapshot the token this attempt is sent with (renewal on), so a 401 is
60
+ # attributed to it rather than to whatever another thread installs since.
61
+ used = @config.renew && @config.auth.access_token
62
+ attempt_request(method, path, params, body, token: used, attempt: attempt, renewed: renewed)
61
63
  rescue *RETRYABLE => e
62
64
  attempt += 1
63
65
  raise if attempt > @config.max_retries || !retryable?(method, e)
@@ -84,20 +86,12 @@ module Kit
84
86
  error.is_a?(RateLimitError) || IDEMPOTENT.include?(method)
85
87
  end
86
88
 
87
- # The access token this request will be sent with, when renewal is on (nil
88
- # otherwise): snapshotted so the 401 is attributed to the token that
89
- # earned it, not to whatever another thread has installed since.
90
- def renewable_token
91
- @config.renew && @config.auth.access_token
92
- end
93
-
94
- # Answers a 401 on `used`. Returns true when the request should be retried:
95
- # either another thread already installed a newer token (use it, do not
96
- # renew again), or config.renew returned a replacement. False when there
97
- # is no callable or it answered nil (cannot renew) — unless a newer token
98
- # arrived while it was deciding, which is still worth a retry. The
99
- # replacement is installed compare-and-swap against `used`, so a renewal
100
- # that completes after a newer token was installed does not roll it back.
89
+ # Answers a 401 on `used`. True when the request should be retried: another
90
+ # thread already installed a newer token (use it, do not renew again), or
91
+ # config.renew returned a replacement. False when there is no callable or
92
+ # it answered nil — unless a newer token arrived meanwhile, still worth a
93
+ # retry. The replacement is installed compare-and-swap against `used`, so
94
+ # a renewal that completes after a newer token cannot roll it back.
101
95
  def renew_token?(used)
102
96
  renew = @config.renew
103
97
  return false unless renew
@@ -112,8 +106,21 @@ module Kit
112
106
  true
113
107
  end
114
108
 
109
+ # One HTTP attempt, instrumented (see Instrumentation): perform + handle.
110
+ # `attempt` is the retry budget (429/5xx/transport only); the renewal retry
111
+ # does not consume it but is still an earlier attempt, so it is reported.
112
+ def attempt_request(method, path, params, body, token:, attempt:, renewed:)
113
+ retries = attempt + (renewed ? 1 : 0)
114
+ Instrumentation.around(@config.instrumenter, method, path, retries) do
115
+ handle(perform(method, path, params, body, token), method, path)
116
+ end
117
+ end
118
+
119
+ # The auth header is added per request as a new immutable http.rb chain —
120
+ # for the given token snapshot, or the credential's current header — so a
121
+ # renewed token is used without rebuilding @client.
115
122
  def perform(method, path, params, body, token = nil)
116
- authed_client(token).request(method, "#{@config.base_url}#{path}", params: params, json: body)
123
+ authed(token).request(method, "#{@config.base_url}#{path}", params: params, json: body)
117
124
  rescue HTTP::TimeoutError => e
118
125
  raise TimeoutError, "#{method.to_s.upcase} #{path} timed out: #{e.message}"
119
126
  rescue HTTP::ConnectionError => e
@@ -122,12 +129,7 @@ module Kit
122
129
  raise TransportError, "#{method.to_s.upcase} #{path} failed in transport: #{e.message}"
123
130
  end
124
131
 
125
- # The shared client plus the auth header (a new immutable chain each time,
126
- # so a renewed token is used without rebuilding @client) — for the given
127
- # OAuth token snapshot, or the credential's current header.
128
- def authed_client(token = nil)
129
- @client.headers(token ? @config.auth.headers_for(token) : @config.auth.headers)
130
- end
132
+ def authed(token) = @client.headers(token ? @config.auth.headers_for(token) : @config.auth.headers)
131
133
 
132
134
  def default_headers
133
135
  {
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ # Observability for requests. Configuration#instrumenter receives one event
5
+ # per HTTP round-trip (so a retried request produces one event per attempt):
6
+ #
7
+ # instrumenter.instrument("request.kit", payload) { ... }
8
+ #
9
+ # The interface is ActiveSupport::Notifications' — pass
10
+ # `ActiveSupport::Notifications` itself in a Rails app — but the gem has no
11
+ # Rails dependency: anything responding to `instrument(name, payload) { }`
12
+ # works. The payload is filled in as the request runs (mutated in place, as
13
+ # ActiveSupport does), so subscribers see the final values:
14
+ #
15
+ # method: :get, :post, ...
16
+ # path: "/v4/subscribers/42" — never the query string (it can carry
17
+ # an email address) and never the body
18
+ # status: Integer, or nil when no response was received
19
+ # duration: seconds, Float
20
+ # retries: how many earlier attempts this request has made (0 = first)
21
+ # retry_after: the parsed Retry-After on a 429, else nil
22
+ # error: the Kit error class name when the attempt failed, else nil
23
+ #
24
+ # No header or credential value is ever included; Auth::Credential.mask is
25
+ # for inspect output, not for payloads, which simply omit secrets.
26
+ module Instrumentation
27
+ EVENT = "request.kit"
28
+
29
+ # Runs one HTTP attempt inside an instrumenter event, filling the payload
30
+ # in as it completes (including on failure) and re-raising. `block` returns
31
+ # [status, body]. With no instrumenter this is a plain yield.
32
+ #
33
+ # A failure is captured inside the block and re-raised only after
34
+ # `instrument` has returned: ActiveSupport::Notifications would otherwise
35
+ # rescue it and add `:exception` / `:exception_object` to this very
36
+ # payload — the latter a Kit::APIError still holding the response body —
37
+ # which a subscriber forwarding the payload wholesale would then leak.
38
+ def self.around(instrumenter, method, path, retries)
39
+ return yield unless instrumenter
40
+
41
+ payload = new_payload(method, path, retries)
42
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ result = failure = nil
44
+ instrumenter.instrument(EVENT, payload) do
45
+ result = yield
46
+ payload[:status] = result.first
47
+ rescue Error => e
48
+ failure = e
49
+ record_error(payload, e)
50
+ ensure
51
+ payload[:duration] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
52
+ end
53
+ raise failure if failure
54
+
55
+ # The request's own result, never the instrumenter's return value:
56
+ # observability must not change what a client method returns. nil here
57
+ # means the instrumenter never yielded, so no request was made.
58
+ result || raise(ConfigurationError, "instrumenter did not yield to the request block")
59
+ end
60
+
61
+ # The query string is dropped here, not left to callers: Connection#request
62
+ # is public and a path passed with `?email_address=...` inline must not
63
+ # reach a breadcrumb either.
64
+ def self.new_payload(method, path, retries)
65
+ { method: method, path: path.split(/[?#]/, 2).first.to_s, status: nil, duration: nil,
66
+ retries: retries, retry_after: nil, error: nil }
67
+ end
68
+
69
+ def self.record_error(payload, error)
70
+ payload[:error] = error.class.name
71
+ payload[:status] = error.status if error.is_a?(APIError)
72
+ payload[:retry_after] = error.retry_after if error.is_a?(RateLimitError)
73
+ end
74
+
75
+ # Wraps a plain Logger as an instrumenter: one debug line per attempt.
76
+ # Used for Configuration#logger; you can also pass it as `instrumenter:`.
77
+ class LoggerInstrumenter
78
+ def initialize(logger)
79
+ @logger = logger
80
+ end
81
+
82
+ def instrument(_name, payload)
83
+ yield payload
84
+ ensure
85
+ @logger.debug { format_line(payload) }
86
+ end
87
+
88
+ private
89
+
90
+ def format_line(payload)
91
+ line = "kit #{payload[:method].to_s.upcase} #{payload[:path]} status=#{payload[:status] || "-"} " \
92
+ "duration=#{format("%.3f", payload[:duration] || 0)}s retries=#{payload[:retries]}"
93
+ line += " retry_after=#{payload[:retry_after]}" if payload[:retry_after]
94
+ line += " error=#{payload[:error]}" if payload[:error]
95
+ line
96
+ end
97
+ end
98
+
99
+ # Fans one event out to several instrumenters (instrumenter: and logger:
100
+ # given together). Blocks nest so each sees the same payload.
101
+ class Multi
102
+ def initialize(instrumenters)
103
+ @instrumenters = instrumenters
104
+ end
105
+
106
+ # Yields the payload to the caller's block like every instrumenter does,
107
+ # forwarding whatever each layer yields so nesting stays transparent.
108
+ def instrument(name, payload, &block)
109
+ @instrumenters.reverse.inject(block) do |inner, instrumenter|
110
+ ->(yielded) { instrumenter.instrument(name, yielded) { |inner_payload| inner.call(inner_payload) } }
111
+ end.call(payload)
112
+ end
113
+ end
114
+
115
+ # Resolves Configuration's instrumenter:/logger: pair into one instrumenter
116
+ # (or nil when neither is set).
117
+ def self.build(instrumenter: nil, logger: nil)
118
+ list = []
119
+ if instrumenter
120
+ unless instrumenter.respond_to?(:instrument)
121
+ raise ConfigurationError, "instrumenter: must respond to #instrument"
122
+ end
123
+
124
+ list << instrumenter
125
+ end
126
+ if logger
127
+ raise ConfigurationError, "logger: must respond to #debug" unless logger.respond_to?(:debug)
128
+
129
+ list << LoggerInstrumenter.new(logger)
130
+ end
131
+ return nil if list.empty?
132
+
133
+ list.size == 1 ? list.first : Multi.new(list)
134
+ end
135
+ end
136
+ end
@@ -107,10 +107,7 @@ module Kit
107
107
 
108
108
  private
109
109
 
110
- # Normalised names, first spelling wins, one per case-insensitive key.
111
- def distinct_tag_names(tag_names)
112
- Array(tag_names).map { |name| Tags.normalize_name(name) }.uniq(&:downcase)
113
- end
110
+ def distinct_tag_names(tag_names) = TagNames.distinct(tag_names)
114
111
 
115
112
  def apply_tag(subscriber, name)
116
113
  tag = @tags.ensure(name: name)
@@ -12,22 +12,12 @@ module Kit
12
12
  end
13
13
 
14
14
  # Kit matches tag names case-insensitively and v3 consumers carry over a
15
- # "find_tag_by_name or create" dance that v4 no longer needs. This is the
16
- # canonical form of a name for that matching: runs of Unicode whitespace
17
- # (`[[:space:]]`, not `\s`, so a fullwidth or typographic space cannot
18
- # mint a look-alike tag) collapsed to one ASCII space and trimmed.
19
- # Raises ArgumentError when nothing is left.
20
- def self.normalize_name(name)
21
- normalized = name.to_s.gsub(/[[:space:]]+/, " ").strip
22
- raise ArgumentError, "tag name must not be blank" if normalized.empty?
15
+ # "find_tag_by_name or create" dance that v4 no longer needs. These are
16
+ # the canonical form of a name for that matching and its match key; the
17
+ # rules live in Kit::TagNames so Kit::Testing shares them exactly.
18
+ def self.normalize_name(name) = TagNames.normalize(name)
23
19
 
24
- normalized
25
- end
26
-
27
- # The key two names share when Kit would treat them as the same tag.
28
- def self.name_key(name)
29
- normalize_name(name).downcase
30
- end
20
+ def self.name_key(name) = TagNames.key(name)
31
21
 
32
22
  # GET /v4/tags
33
23
  def list(**params)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ # How Kit matches tag names, in one dependency-free place so the client
5
+ # (Resources::Tags#ensure, Subscribers#upsert_and_tag) and Kit::Testing's
6
+ # factories agree exactly: runs of Unicode whitespace (`[[:space:]]`, not
7
+ # `\s`, so a fullwidth or typographic space cannot mint a look-alike tag)
8
+ # collapsed to one ASCII space and trimmed; then matched case-insensitively.
9
+ module TagNames
10
+ # The canonical spelling. Raises ArgumentError when nothing is left.
11
+ def self.normalize(name)
12
+ normalized = name.to_s.gsub(/[[:space:]]+/, " ").strip
13
+ raise ArgumentError, "tag name must not be blank" if normalized.empty?
14
+
15
+ normalized
16
+ end
17
+
18
+ # The key two names share when Kit would treat them as the same tag.
19
+ def self.key(name)
20
+ normalize(name).downcase
21
+ end
22
+
23
+ # Normalised names, first spelling wins, one per case-insensitive key —
24
+ # the set of tags upsert_and_tag applies for `names`.
25
+ def self.distinct(names)
26
+ Array(names).map { |name| normalize(name) }.uniq(&:downcase)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The value objects are plain Data classes with no dependency on http.rb, so
4
+ # they are loaded here directly: `require "kit/testing"` stays usable without
5
+ # the gem's runtime dependencies installed (the standalone guarantee).
6
+ %w[account creator_profile subscriber tag custom_field sequence sequence_email broadcast broadcast_stats
7
+ post snippet purchase webhook webhook_endpoint email_stats growth_stats subscriber_stats
8
+ tagged_subscriber].each { |object| require_relative "../objects/#{object}" }
9
+ require_relative "../auth/credential"
10
+ require_relative "../oauth/token"
11
+ require_relative "../tag_names"
12
+
13
+ module Kit
14
+ module Testing # rubocop:disable Style/Documentation -- documented in lib/kit/testing.rb
15
+ # Typed value objects for `instance_double` returns and direct unit tests,
16
+ # built from the same documented examples as the envelope builders:
17
+ #
18
+ # Kit::Testing.subscriber(id: 500, email_address: "ada@example.com")
19
+ # # => #<data Kit::Objects::Subscriber id=500, email_address="ada@example.com", state="active", ...>
20
+ # Kit::Testing.account_info(plan_type: "free") # => Kit::Objects::AccountInfo
21
+ # Kit::Testing.oauth_token(expires_in: 3600) # => Kit::OAuth::Token
22
+ # Kit::Testing.tagged_subscriber(tag_names: ["vip", "beta"])
23
+ #
24
+ # Each `<object>` factory is the `<object>_json` builder's envelope object
25
+ # parsed by the Kit::Objects class the resource itself uses, so a factory
26
+ # cannot produce a value the real client could not — the same field
27
+ # validation applies to overrides.
28
+ module Factories
29
+ # <object> factory => the Kit::Objects class that parses its envelope
30
+ # object (the operation comes from Testing::OBJECTS under the same key).
31
+ # `account` is the account object alone; `account_info` below builds the
32
+ # whole GET /v4/account result.
33
+ OBJECT_CLASSES = {
34
+ subscriber: Objects::Subscriber,
35
+ tag: Objects::Tag,
36
+ custom_field: Objects::CustomField,
37
+ sequence: Objects::Sequence,
38
+ sequence_email: Objects::SequenceEmail,
39
+ broadcast: Objects::Broadcast,
40
+ broadcast_stats: Objects::BroadcastStats,
41
+ post: Objects::Post,
42
+ snippet: Objects::Snippet,
43
+ purchase: Objects::Purchase,
44
+ webhook: Objects::Webhook,
45
+ webhook_endpoint: Objects::WebhookEndpoint,
46
+ creator_profile: Objects::CreatorProfile,
47
+ email_stats: Objects::EmailStats,
48
+ growth_stats: Objects::GrowthStats,
49
+ subscriber_stats: Objects::SubscriberStats,
50
+ account: Objects::Account
51
+ }.freeze
52
+
53
+ # Kit's documented token response, for Kit::OAuth::Token.
54
+ TOKEN_EXAMPLE = {
55
+ "access_token" => "<ACCESS_TOKEN>",
56
+ "refresh_token" => "<REFRESH_TOKEN>",
57
+ "token_type" => "Bearer",
58
+ "expires_in" => 172_800,
59
+ "scope" => "public",
60
+ "created_at" => 1_700_000_000
61
+ }.freeze
62
+
63
+ # The typed object for `factory` (:subscriber, :tag, ...) with overrides
64
+ # applied to its documented example.
65
+ def object(factory, **overrides)
66
+ klass = OBJECT_CLASSES.fetch(factory.to_sym) do
67
+ raise ArgumentError,
68
+ "unknown Kit::Testing factory #{factory.inspect}; known: #{OBJECT_CLASSES.keys.join(", ")}"
69
+ end
70
+ klass.from(attributes(OBJECTS.fetch(factory.to_sym), **overrides))
71
+ end
72
+
73
+ # The whole GET /v4/account result. Account overrides apply to
74
+ # `account`; pass `user:` to override user fields (validated against the
75
+ # documented user example like every other override).
76
+ def account_info(user: {}, **account_overrides)
77
+ body = account_json(**account_overrides)
78
+ Objects::AccountInfo.new(
79
+ user: Objects::User.from(merge_known("user", body.fetch("user"), user)),
80
+ account: Objects::Account.from(body.fetch("account"))
81
+ )
82
+ end
83
+
84
+ # A Kit::OAuth::Token from Kit's documented token response. `expires_in`
85
+ # 172800 (48 h) and `created_at` are set, so #expires_at / #expired? /
86
+ # #expiring_within? work; pass `created_at:` to place it in time.
87
+ def oauth_token(**overrides)
88
+ OAuth::Token.from(merge_known("token", TOKEN_EXAMPLE, overrides))
89
+ end
90
+
91
+ # Subscribers#upsert_and_tag's result, shaped as production would: names
92
+ # normalised and de-duplicated the way Kit matches them (Kit::TagNames,
93
+ # the same code the resource runs), one Tag per distinct name with ids
94
+ # ascending from the tag example's, around a subscriber built from the
95
+ # remaining overrides.
96
+ def tagged_subscriber(tag_names: ["vip"], **subscriber_overrides)
97
+ base = object(:tag)
98
+ tags = TagNames.distinct(tag_names).each_with_index.map { |name, i| object(:tag, id: base.id + i, name: name) }
99
+ Objects::TaggedSubscriber.new(subscriber: object(:subscriber, **subscriber_overrides), tags: tags)
100
+ end
101
+
102
+ private
103
+
104
+ # `overrides` over `example`, rejecting a key the documented example does
105
+ # not carry — the same rule the envelope builders apply to their types.
106
+ def merge_known(what, example, overrides)
107
+ unknown = overrides.keys.map(&:to_s) - example.keys
108
+ if unknown.any?
109
+ raise ArgumentError,
110
+ "#{unknown.first.inspect} is not a field of #{what.inspect}; known: #{example.keys.join(", ")}"
111
+ end
112
+
113
+ example.merge(overrides.transform_keys(&:to_s))
114
+ end
115
+ end
116
+
117
+ extend Factories
118
+
119
+ # Kit::Testing.subscriber(id: 1) etc., one per OBJECTS entry.
120
+ Factories::OBJECT_CLASSES.each_key do |factory|
121
+ define_singleton_method(factory) { |**overrides| object(factory, **overrides) }
122
+ end
123
+ end
124
+ end