calmlens 0.1.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'classification'
4
+
5
+ module CalmLens
6
+ # A moderation decision for {MockServerClient}'s `moderate:` option or
7
+ # {MockServerClient#complete_processing}, started with {.of}: flag the categories it found, with
8
+ # their scores and qualifiers, and mark the categories it couldn't assess. Each call checks its
9
+ # names and scores and raises `sdk/invalid-argument` for one CalmLens doesn't have.
10
+ #
11
+ # @example
12
+ # CalmLens::MockDecision.of('blocked').flag('violence', 0.9, 'violence-threat' => 0.9).not_evaluated('profanity')
13
+ class MockDecision
14
+ # Where the mock's moderation leaves an upload: `pending` (still processing), `failed`
15
+ # (processing failed, retryable), or a decision with that moderation status (`purged` also
16
+ # removes the content).
17
+ STATUSES = %w[pending failed allowed limited age-restricted blocked purged].freeze
18
+ # The policy action that gives each decided status.
19
+ STATUS_ACTIONS = { 'allowed' => 'allow', 'limited' => 'limit', 'age-restricted' => 'age-restrict', 'blocked' => 'block',
20
+ 'purged' => 'purge' }.freeze
21
+ private_constant :STATUS_ACTIONS
22
+
23
+ # @return [String]
24
+ attr_reader :status
25
+
26
+ # Starts a decision with this status.
27
+ # @param status [String] one of {STATUSES}
28
+ # @return [MockDecision]
29
+ def self.of(status) = new(status)
30
+
31
+ # A `moderate:` answer or {MockServerClient#complete_processing} decision as a checked decision.
32
+ # @api private
33
+ def self.from(value)
34
+ return value if value.is_a?(MockDecision)
35
+ return new(value) if value.is_a?(String)
36
+
37
+ raise Internal.invalid("A decision must be a status or a CalmLens::MockDecision (got #{value.inspect})")
38
+ end
39
+
40
+ # @param status [String] one of {STATUSES}
41
+ # @raise [InvalidArgumentError] for an unknown status
42
+ def initialize(status)
43
+ raise Internal.invalid("status must be one of #{STATUSES.join(', ')} (got #{status.inspect})") unless STATUSES.include?(status)
44
+
45
+ @status = status
46
+ @flags = {}
47
+ @not_evaluated = []
48
+ end
49
+
50
+ # Records findings in `category` with its `score` (0 to 1) and, optionally, scores for its
51
+ # qualifiers, such as `{ 'violence-threat' => 0.9 }`. The policy acts on a category at 0.5 and
52
+ # above, so `flaggedCategories` lists it there unless the status is `allowed`.
53
+ # @param category [String]
54
+ # @param score [Numeric]
55
+ # @param qualifiers [Hash{String => Numeric}]
56
+ # @return [self]
57
+ # @raise [InvalidArgumentError] for an unknown category or qualifier, a score outside 0 to 1, a
58
+ # category already in the decision, or a `pending` or `failed` decision
59
+ def flag(category, score, qualifiers = {})
60
+ category = check_category(category)
61
+ raise Internal.invalid("#{category}'s score must be from 0 to 1") unless score?(score)
62
+
63
+ checked = qualifiers.to_h do |qualifier, qualifier_score|
64
+ qualifier = qualifier.to_s
65
+ known = MockClassification::QUALIFIERS[category].include?(qualifier)
66
+ raise Internal.invalid("#{qualifier} is not a qualifier of #{category}") unless known
67
+ raise Internal.invalid("#{qualifier}'s score must be from 0 to 1") unless score?(qualifier_score)
68
+
69
+ [qualifier, qualifier_score]
70
+ end
71
+ @flags[category] = { score: score, qualifiers: checked }
72
+ self
73
+ end
74
+
75
+ # Marks `category` as not assessed (`not-evaluated` for reason `not-supported`).
76
+ # @param category [String]
77
+ # @return [self]
78
+ # @raise [InvalidArgumentError] like {#flag}
79
+ def not_evaluated(category)
80
+ @not_evaluated << check_category(category)
81
+ self
82
+ end
83
+
84
+ # The report a decided asset gets, as the API sends it: every category (unflagged ones
85
+ # evaluated with score 0), the policy's actions and `flaggedCategories`.
86
+ # @return [Hash{String => Object}, nil] `categories`, `score` and `policy`; nil for `pending` and `failed`
87
+ def report
88
+ return if undecided?
89
+
90
+ evaluated = (MockClassification::CATEGORIES - @not_evaluated).map { |category| [category, @flags.dig(category, :score) || 0] }
91
+ { 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] } },
92
+ 'categories' => evaluated.to_h { |category, value| [category, { 'score' => value, 'action' => action(value) }] },
93
+ 'explanationComplete' => true, 'source' => 'policy', 'automatedAction' => STATUS_ACTIONS[@status],
94
+ 'regions' => [], 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
95
+ end
96
+
97
+ def inspect = "#<#{self.class.name} #{@status}>"
98
+
99
+ private
100
+
101
+ def undecided? = %w[pending failed].include?(@status)
102
+
103
+ def check_category(category)
104
+ category = category.to_s
105
+ raise Internal.invalid("#{category} is not a CalmLens category") unless MockClassification::CATEGORIES.include?(category)
106
+ raise Internal.invalid("a #{@status} decision has no findings") if undecided?
107
+ raise Internal.invalid("#{category} is already in this decision") if @flags.key?(category) || @not_evaluated.include?(category)
108
+
109
+ category
110
+ end
111
+
112
+ def score?(score) = score.is_a?(Numeric) && score.real? && score.finite? && score >= 0 && score <= 1
113
+
114
+ def action(score) = @status != 'allowed' && score >= 0.5 ? STATUS_ACTIONS[@status] : 'allow'
115
+
116
+ # The API's likelihood for a score (ClassificationCategories.ts, scoreToLikelihood).
117
+ def likelihood(score)
118
+ if score >= 0.8 then 'very-likely'
119
+ elsif score >= 0.6 then 'likely'
120
+ elsif score >= 0.4 then 'possible'
121
+ elsif score >= 0.2 then 'unlikely'
122
+ else 'very-unlikely'
123
+ end
124
+ end
125
+
126
+ def category_report(category)
127
+ return { 'status' => 'not-evaluated', 'reason' => 'not-supported' } if @not_evaluated.include?(category)
128
+
129
+ flag = @flags.fetch(category, { score: 0, qualifiers: {} })
130
+ result = { 'status' => 'evaluated', 'likelihood' => likelihood(flag[:score]), 'score' => flag[:score] }
131
+ unless flag[:qualifiers].empty?
132
+ result['qualifiers'] = flag[:qualifiers].to_h do |qualifier, some|
133
+ [qualifier, { 'status' => 'evaluated', 'someScore' => some, 'likelihood' => likelihood(some) }]
134
+ end
135
+ end
136
+ result
137
+ end
138
+ end
139
+
140
+ # What {MockServerClient}'s `moderate:` callback receives for each asset the mock moderates.
141
+ #
142
+ # - `asset` [Hash] the asset as stored, still processing (a copy).
143
+ # - `content` [Content] the uploaded bytes and, for a `plaintext-document`, their text.
144
+ # - `trigger` [String] why the mock is moderating it: `upload` (`assets.create`, `assets.classify`,
145
+ # `text.classify` or a final part), `retry` (`assets.retry`), `rerun` (`assets.explain` with
146
+ # `rerun: true`) or `complete` ({MockServerClient#complete_processing} without a decision).
147
+ # - `queued` [Boolean] true when the API would queue the work rather than answer with the
148
+ # decision: queued uploads, retries, reruns and `complete`.
149
+ MockModerationInput = Struct.new(:asset, :content, :trigger, :queued, keyword_init: true) do
150
+ # @return [Boolean]
151
+ def queued? = queued
152
+ end
153
+
154
+ # The content of a {MockModerationInput}: `bytes` is the uploaded bytes (binary; nil without
155
+ # stored content, such as a website or removed content, and multipart parts joined in order),
156
+ # and `text` is them decoded as UTF-8 for a `plaintext-document` (else nil).
157
+ MockModerationInput::Content = Struct.new(:bytes, :text, keyword_init: true)
158
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module CalmLens
6
+ module Testing
7
+ # Renders a ThumbHash as the small PNG the CDN serves for an asset's `placeholder` resource,
8
+ # byte for byte like the CDN's renderer: the ThumbHash decoded to RGBA, stored uncompressed.
9
+ # @api private
10
+ module Placeholder
11
+ module_function
12
+
13
+ # @param hash [String] the base64 ThumbHash
14
+ # @return [String] PNG bytes
15
+ def png(hash)
16
+ width, height, rgba = rgba(hash.unpack1('m0').bytes)
17
+ encode(width, height, rgba)
18
+ end
19
+
20
+ # Decodes ThumbHash bytes to its width, height and RGBA pixels (RGB not premultiplied).
21
+ def rgba(hash)
22
+ header = hash[0] | (hash[1] << 8) | (hash[2] << 16)
23
+ alpha = (header >> 23) == 1
24
+ landscape = ((hash[3] | (hash[4] << 8)) >> 15) == 1
25
+ short = alpha ? 5 : 7
26
+ lx = [3, landscape ? short : hash[3] & 7].max
27
+ ly = [3, landscape ? hash[3] & 7 : short].max
28
+ channels = channels(hash, header, alpha, lx, ly)
29
+ ratio = aspect_ratio(hash)
30
+ width = (ratio > 1 ? 32 : 32 * ratio).round
31
+ height = (ratio > 1 ? 32 / ratio : 32).round
32
+ pixels = []
33
+ height.times do |y|
34
+ width.times do |x|
35
+ fx = Array.new([lx, alpha ? 5 : 3].max) { |cx| Math.cos(Math::PI / width * (x + 0.5) * cx) }
36
+ fy = Array.new([ly, alpha ? 5 : 3].max) { |cy| Math.cos(Math::PI / height * (y + 0.5) * cy) }
37
+ pixels.concat(pixel(channels, fx, fy, lx, ly, alpha))
38
+ end
39
+ end
40
+ [width, height, pixels]
41
+ end
42
+
43
+ # The DC values and AC coefficients of the luminance, colour and alpha channels.
44
+ def channels(hash, header, alpha, lum_cols, lum_rows)
45
+ header16 = hash[3] | (hash[4] << 8)
46
+ index = 0
47
+ start = alpha ? 6 : 5
48
+ decode = lambda do |cols, rows, scale|
49
+ values = []
50
+ each_term(cols, rows) do
51
+ values << (((((hash[start + (index >> 1)] >> ((index & 1) << 2)) & 15) / 7.5) - 1) * scale)
52
+ index += 1
53
+ end
54
+ values
55
+ end
56
+ { l_dc: (header & 63) / 63.0, p_dc: (((header >> 6) & 63) / 31.5) - 1, q_dc: (((header >> 12) & 63) / 31.5) - 1,
57
+ a_dc: alpha ? (hash[5] & 15) / 15.0 : 1.0,
58
+ l: decode.call(lum_cols, lum_rows, ((header >> 18) & 31) / 31.0), p: decode.call(3, 3, ((header16 >> 3) & 63) / 63.0 * 1.25),
59
+ q: decode.call(3, 3, ((header16 >> 9) & 63) / 63.0 * 1.25), a: alpha ? decode.call(5, 5, (hash[5] >> 4) / 15.0) : [] }
60
+ end
61
+
62
+ # Yields the index of each AC coefficient of an `nx` by `ny` channel with its cosine indexes.
63
+ def each_term(cols, rows)
64
+ index = 0
65
+ rows.times do |cy|
66
+ cx = cy.zero? ? 1 : 0
67
+ while cx * rows < cols * (rows - cy)
68
+ yield index, cx, cy
69
+ index += 1
70
+ cx += 1
71
+ end
72
+ end
73
+ end
74
+
75
+ def pixel(channels, cos_x, cos_y, lum_cols, lum_rows, alpha)
76
+ l = channels[:l_dc]
77
+ each_term(lum_cols, lum_rows) { |j, cx, cy| l += channels[:l][j] * cos_x[cx] * cos_y[cy] * 2 }
78
+ p = channels[:p_dc]
79
+ q = channels[:q_dc]
80
+ each_term(3, 3) do |j, cx, cy|
81
+ f = cos_x[cx] * cos_y[cy] * 2
82
+ p += channels[:p][j] * f
83
+ q += channels[:q][j] * f
84
+ end
85
+ a = channels[:a_dc]
86
+ each_term(5, 5) { |j, cx, cy| a += channels[:a][j] * cos_x[cx] * cos_y[cy] * 2 } if alpha
87
+ b = l - (2 / 3.0 * p)
88
+ r = ((3 * l) - b + q) / 2
89
+ [r, r - q, b, a].map { |value| [0, 255 * [1, value].min].max.to_i }
90
+ end
91
+
92
+ def aspect_ratio(hash)
93
+ short = hash[2].anybits?(128) ? 5 : 7
94
+ sides = hash[4].anybits?(128) ? [short, hash[3] & 7] : [hash[3] & 7, short]
95
+ sides[0].to_f / sides[1]
96
+ end
97
+
98
+ # An uncompressed PNG: one stored deflate block per scanline.
99
+ def encode(width, height, rgba)
100
+ row = (width * 4) + 1
101
+ lines = Array.new(height) { |y| "\0".b + rgba[y * width * 4, width * 4].pack('C*') }
102
+ data = "\x78\x01".b
103
+ lines.each_with_index { |line, y| data << [y + 1 < height ? 0 : 1, row, row ^ 0xffff].pack('Cvv') << line }
104
+ data << [Zlib.adler32(lines.join)].pack('N')
105
+ chunk = ->(type, body) { [body.bytesize].pack('N') + type.b + body + [Zlib.crc32(type.b + body)].pack('N') }
106
+ "\x89PNG\r\n\x1a\n".b + chunk.call('IHDR', [width, height, 8, 6, 0, 0, 0].pack('NNC5')) +
107
+ chunk.call('IDAT', data) + chunk.call('IEND', ''.b)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -11,26 +11,42 @@ module CalmLens
11
11
  # error handling, then reach an in-memory stand-in for the API and CDN instead of the network.
12
12
  #
13
13
  # By default the stand-in is stateful: uploads create assets you can retrieve, list, filter,
14
- # move and delete, folders behave like the API's, the CDN serves stored bytes, and errors use
15
- # the API's codes, HTTP statuses and body shape. It does not classify content: assets moderated
16
- # synchronously get `analyzed_outcome` and queued ones `queued_outcome`. Content is kept only with
17
- # `keepContentAfterProcessing: true`; otherwise it is removed once processing is `done`, as in the API.
14
+ # search, move and delete, folders and hash lists behave like the API's, the CDN serves stored bytes, and errors use
15
+ # the API's codes, HTTP statuses and body shape. It does not classify content: the `moderate:`
16
+ # callback decides each upload, retry and rerun with a status or a {MockDecision}, and the events
17
+ # feed gets the pipeline's `asset.uploaded`, `asset.classified`, `asset.<status>` and `asset.failed`
18
+ # events. Without it, queued uploads stay `pending` until {#complete_processing} and the rest are
19
+ # `allowed`. Content is kept only with `keepContentAfterProcessing: true`; otherwise it is removed
20
+ # once classification is `completed`, as in the API.
21
+ # A `purged` outcome removes the content with reason `policy`. The CDN answers like the real one:
22
+ # `asset/not-available` (403) for blocked, purged, failed or removed content,
23
+ # `viewer/age-confirmation-required` (403) for an age-restricted asset without
24
+ # `viewer_claims: {age_confirmed: true}`, whatever the key, and `asset/api-key-missing-permission` (403)
25
+ # for a private asset unless `api_key` comes from {.create_api_key} with an `assets:download`
26
+ # grant. The default key has no grants. {#decide} applies a reviewer's decision, and
27
+ # {Assets#wait_for_decision} takes no real time: a pending asset times out at once.
28
+ # Filters join comparisons with `;` (and), which binds tighter than `,` (or), and group them with
29
+ # `( … )`; list cursors resume after the last item returned. {Assets#search} is the API's keyword
30
+ # search: every query word (case-insensitive, up to 8) must appear in a searchable field, and
31
+ # results carry the API's `score` and `matchedFields`, ranked by score and then newest first.
18
32
  #
19
33
  # To script exact responses, enqueue them: queued responses and errors are used first, in order,
20
34
  # before the stand-in answers.
21
35
  #
22
36
  # @example
23
- # mock = CalmLens::MockServerClient.new
24
- # asset = mock.assets.moderate(name: 'note', content: {plain_text: 'hi', type: 'text/plain'})
25
- # mock.assets.retrieve(asset_id: asset['id']).dig('moderation', 'status') # => "approved"
37
+ # mock = CalmLens::MockServerClient.new(moderate: ->(input) { input.content.text&.include?('spam') ? 'blocked' : 'allowed' })
38
+ # asset = mock.assets.classify(name: 'note', content: {plain_text: 'hi', type: 'text/plain'})
39
+ # mock.assets.retrieve(asset_id: asset['id']).dig('classification', 'moderation', 'status') # => "allowed"
26
40
  # mock.enqueue(status: 503, body: {code: 'server/unavailable', message: 'Try again'})
27
41
  class MockServerClient < ServerClient
28
42
  # A request the mock received.
29
43
  #
30
- # `path` is relative to `base_url` for API calls (`/projects/<id>/assets`) and to
31
- # `cdn_base_url` for CDN requests. `query` holds the decoded query string. `body` is the bytes
32
- # sent: JSON text, or the uploaded content (streams are read), or nil.
33
- Request = Struct.new(:method, :url, :path, :query, :headers, :body, keyword_init: true) do
44
+ # `path` is relative to `base_url` for API calls (`/projects/<id>/assets`, without `/v1`) and to
45
+ # `cdn_base_url` for CDN requests, with each segment decoded. `query` holds the decoded query
46
+ # string. `body` is the bytes sent: JSON text, or the uploaded content (streams are read), or
47
+ # nil. `status` is the HTTP status the mock answered with, or nil when the request failed
48
+ # without a response.
49
+ Request = Struct.new(:method, :url, :path, :query, :headers, :body, :status, keyword_init: true) do
34
50
  # @return [Object] the body parsed as JSON.
35
51
  def json = body && JSON.parse(body)
36
52
  end
@@ -38,33 +54,193 @@ module CalmLens
38
54
  # @return [Testing::State] the stored assets, folders, content and events.
39
55
  attr_reader :state
40
56
 
57
+ # The prefix of mock API keys made by {.create_api_key}.
58
+ API_KEY_PREFIX = 'sk_test_'
59
+ # Simulated failures for {#fail_next_request}, besides API error codes and exceptions.
60
+ REQUEST_FAILURES = %w[transport-error timeout].freeze
61
+ # The methods {#fail_next_request} can target, by resource. Iterators are their list calls.
62
+ OPERATIONS = { assets: %i[search list retrieve list_children update create classify upload_part create_signed_url move
63
+ retry explain wait_for_decision delete_content delete batch],
64
+ folders: %i[list retrieve create update delete move],
65
+ hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
66
+ text: %i[classify], events: %i[list],
67
+ cdn: %i[serve] }.freeze
68
+ private_constant :OPERATIONS
69
+ # The operation names {#fail_next_request} accepts, in their camelCase form.
70
+ OPERATION_NAMES = (OPERATIONS.flat_map do |resource, methods|
71
+ methods.map { |name| "#{Internal.camelize(resource)}.#{Internal.camelize(name)}" }
72
+ end + %w[ensureLens ensureLenses]).freeze
73
+ private_constant :OPERATION_NAMES
74
+
75
+ # A mock API key carrying permission grants, such as
76
+ # `[{ actions: ['assets:download'], folderId: nil, includeSubfolders: true }]` for CDN reads of
77
+ # private and age-restricted assets.
78
+ # @param grants [Array<Hash>] snake_case keys are converted.
79
+ # @return [String]
80
+ def self.create_api_key(grants = [])
81
+ API_KEY_PREFIX + [JSON.generate(Internal.normalize(grants))].pack('m0').tr('+/', '-_').delete('=')
82
+ end
83
+
84
+ # The grants a key from {.create_api_key} carries; none for any other key.
85
+ # @api private
86
+ def self.grants_of(api_key)
87
+ return [] unless api_key.start_with?(API_KEY_PREFIX)
88
+
89
+ encoded = api_key.delete_prefix(API_KEY_PREFIX).tr('-_', '+/')
90
+ grants = JSON.parse("#{encoded}#{'=' * (-encoded.size % 4)}".unpack1('m0'))
91
+ grants.is_a?(Array) ? grants.grep(Hash) : []
92
+ rescue ArgumentError, JSON::ParserError
93
+ []
94
+ end
95
+
96
+ # Signs an upload token like {UploadTokens#sign}, with the same checks, at a fixed time and with
97
+ # a fixed nonce, so tests can compare exact tokens.
98
+ # @param api_key [String]
99
+ # @param options [Hash] the options of {UploadTokens#sign}
100
+ # @param now [Integer] the signing time in milliseconds since the Unix epoch
101
+ # @param nonce [String]
102
+ # @return [Hash] `{'token', 'expiresAt'}`
103
+ def self.sign_upload_token(api_key, options, now:, nonce:)
104
+ UploadTokens.sign_at(api_key, options, now: now, nonce: nonce)
105
+ rescue KeyError, TypeError, ArgumentError, JSON::GeneratorError, EncodingError => e
106
+ raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, e.message), cause: e
107
+ end
108
+
109
+ BASE62 = [*'0'..'9', *'A'..'Z', *'a'..'z'].join.freeze
110
+ private_constant :BASE62
111
+
112
+ # A new ID like the API's: `prefix`, `_` and 20 base62 characters from a CSPRNG (bytes 248 to
113
+ # 255 are drawn again, so every character is equally likely).
114
+ # @api private
115
+ def self.new_id(prefix)
116
+ body = +''
117
+ SecureRandom.random_bytes(40).each_byte { |byte| body << BASE62[byte % 62] if byte < 248 } while body.size < 20
118
+ "#{prefix}_#{body[0, 20]}"
119
+ end
120
+
121
+ # A clock in epoch milliseconds that follows the system time but never repeats or goes back.
122
+ # @api private
123
+ def self.monotonic_clock
124
+ last = 0
125
+ lock = Mutex.new
126
+ -> { lock.synchronize { last = [(Time.now.to_f * 1000).to_i, last + 1].max } }
127
+ end
128
+
41
129
  # @param project_id [String]
42
- # @param now [#call] returns the current time in epoch milliseconds.
43
- # @param create_id [#call] returns a new ID.
44
- # @param queued_outcome [String] the outcome of assets created with {Assets#create}: `pending`
45
- # (processing, moderation pending), `approved` or `rejected` (processing done with that
46
- # decision) or `failed` (processing failed, retryable).
47
- # @param analyzed_outcome [String, Hash, #call] the result of {Assets#moderate}: an outcome, fields
48
- # to deep-merge into the asset (`{'moderation' => {'status' => 'rejected', 'action' => 'hide'}}`;
49
- # the outcome follows `moderation.status`, or is `failed` when `processing.status` is `failed`,
50
- # and is `approved` otherwise), or a callable that receives a copy of the asset and returns either.
130
+ # @param api_key [String] a key from {.create_api_key}; the default has no grants.
131
+ # @param now [#call] returns the current time in epoch milliseconds. The default clock reads the
132
+ # system time but advances at least 1 ms per reading, so records made in a row have strictly
133
+ # increasing `createdAt` and order predictably. A clock you pass is used as is.
134
+ # @param create_id [#call] receives the prefix of the kind of object (`asset`, `fldr`, `hl`, `hle`, `lens`,
135
+ # `evt` or `rev`) and returns a new ID. The default is like the API's: the prefix, `_` and 20 random base62
136
+ # characters, such as `asset_5Gm2Rk8XqT3wLp9VbN4c`.
137
+ # @param moderate [#call, nil] decides each asset the mock moderates. It receives a
138
+ # {MockModerationInput} (the stored asset, its content, the trigger and whether the API would
139
+ # queue the work) and returns a status from {MockDecision::STATUSES} or a {MockDecision}. It is
140
+ # called on uploads (`assets.create`, `assets.classify`, `text.classify` and a final part:
141
+ # trigger `upload`), `assets.retry` (`retry`), `assets.explain` with `rerun: true` (`rerun`) and
142
+ # {#complete_processing} without a decision (`complete`). `pending` leaves the asset processing.
143
+ # Without it, queued work other than `complete` stays `pending` and the rest is `allowed`. It
144
+ # runs while the mock handles a request, so it must not call the mock.
145
+ # @param max_retries [Integer] retries after a retryable failure; 0 by default, so a simulated
146
+ # failure reaches the caller.
51
147
  # @param options [Hash] other {ServerClient#initialize} settings, except `api_key` and `transport`.
52
- # Retries wait no time unless a queued response sets `Retry-After`.
53
- def initialize(project_id: 'project-1', base_url: 'https://api.calmlens.invalid/v1',
54
- cdn_base_url: 'https://cdn.calmlens.invalid/v1', now: -> { (Time.now.to_f * 1000).to_i },
55
- create_id: -> { SecureRandom.uuid }, queued_outcome: 'pending', analyzed_outcome: 'approved',
56
- **options)
148
+ # Retries and decision checks wait no real time.
149
+ def initialize(project_id: 'project-1', api_key: MockServerClient.create_api_key, base_url: 'https://api.calmlens.invalid/v1',
150
+ cdn_base_url: 'https://cdn.calmlens.invalid/v1', now: MockServerClient.monotonic_clock,
151
+ create_id: ->(prefix) { MockServerClient.new_id(prefix) }, moderate: nil, max_retries: 0, **options)
152
+ raise Internal.invalid('moderate must respond to call') unless moderate.nil? || moderate.respond_to?(:call)
153
+
57
154
  @mock_lock = Mutex.new
58
155
  @queue = []
59
156
  @received = []
60
- super(api_key: 'mock-key', project_id: project_id, base_url: base_url, cdn_base_url: cdn_base_url,
61
- retry_base_delay: 0, **options, transport: method(:receive))
157
+ @failures = []
158
+ @call_key = :"calmlens_mock_call_#{object_id}"
159
+ super(api_key: api_key, project_id: project_id, base_url: base_url, cdn_base_url: cdn_base_url, max_retries: max_retries,
160
+ **options, transport: method(:receive))
62
161
  @fake = Testing::FakeAPI.new(project_id: project_id, base_url: self.base_url, cdn_base_url: self.cdn_base_url,
63
- now: now, create_id: create_id, queued_outcome: queued_outcome,
64
- analyzed_outcome: analyzed_outcome)
162
+ now: now, create_id: create_id, moderate: moderate, grants: MockServerClient.grants_of(api_key))
65
163
  @state = @fake.state
164
+ # Like the API, the stand-in checks upload sizes itself, in the API's order.
165
+ @session.defer_upload_checks = true
166
+ OPERATIONS.each { |resource, methods| track(public_send(resource), Internal.camelize(resource), methods) }
167
+ # Retries and decision checks advance this clock instead of sleeping.
168
+ @waited = 0.0
169
+ @session.use_clock(monotonic: -> { @mock_lock.synchronize { @waited } },
170
+ pause: ->(seconds) { @mock_lock.synchronize { @waited += seconds } })
171
+ end
172
+
173
+ # Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
174
+ # status changes (`purged` also removes the content), the review joins `manualReviews`, and an
175
+ # `asset.<decision>` event with a `decided-by: reviewer` fact joins the events feed.
176
+ # @param asset_id [String]
177
+ # @param decision [String] `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
178
+ # @return [Hash] the asset
179
+ # @raise [APIError] `asset/not-found` for an unknown asset
180
+ # @raise [InvalidArgumentError] for any other decision, such as `pending` or `failed`
181
+ def decide(asset_id, decision)
182
+ @mock_lock.synchronize { @fake.decide(asset_id, decision) } || raise(not_found)
183
+ end
184
+
185
+ # Finishes processing an asset as the moderation pipeline would: with `decision`, or else what
186
+ # `moderate:` answers for the `complete` trigger (`allowed` without it). A decision adds the
187
+ # `asset.classified` and automated `asset.<status>` events (or `asset.failed`); `pending` leaves
188
+ # the asset processing. It records no manual review.
189
+ # @param asset_id [String]
190
+ # @param decision [String, MockDecision, nil] a status from {MockDecision::STATUSES} or a {MockDecision}
191
+ # @return [Hash] the asset
192
+ # @raise [APIError] `asset/not-found` for an unknown asset
193
+ # @raise [InvalidArgumentError] when the asset isn't processing, or for an unknown decision
194
+ def complete_processing(asset_id, decision = nil)
195
+ @mock_lock.synchronize { @fake.complete_processing(asset_id, decision) } || raise(not_found)
196
+ end
197
+
198
+ # Changes the grants of the mock's API key, as editing the key in the portal would: later
199
+ # requests, such as {CDN#serve}, use them. The key itself stays the same, so idempotency keys it
200
+ # used still replay.
201
+ # @param grants [Array<Hash>] like {.create_api_key}'s
202
+ def api_key_grants=(grants)
203
+ normalized = JSON.parse(JSON.generate(Internal.normalize(grants)))
204
+ @mock_lock.synchronize { @fake.grants = normalized }
205
+ end
206
+
207
+ # Makes the next request (or the next one `operation` sends) fail instead of running, as if it
208
+ # never completed. Each queued failure fails one request, so a client retry takes the next
209
+ # queued failure or reaches the API. Queue several to fail several requests in a row.
210
+ # @param failure [String, Exception] `transport-error` (`sdk/transport-error`), `timeout`
211
+ # (`sdk/timeout`), an API error code such as `upload/limit-exceeded` (answered with its HTTP
212
+ # status), or an exception for the transport to raise.
213
+ # @param operation [String, nil] a method such as `assets.create`, `assets.upload_part`,
214
+ # `cdn.serve` or `ensure_lens` (JavaScript names such as `assets.uploadPart` work too).
215
+ # @param details [Hash, nil] the API error's `details`, for an API error code.
216
+ # @param retry_after [Integer, nil] whole seconds for the API error's `Retry-After` header.
217
+ # @return [self]
218
+ # @raise [InvalidArgumentError] for an unknown failure or operation, or a `retry_after` that isn't
219
+ # a whole number of seconds
220
+ def fail_next_request(failure = 'transport-error', operation: nil, details: nil, retry_after: nil)
221
+ unless failure.is_a?(Exception) || REQUEST_FAILURES.include?(failure) || ErrorCodes::STATUSES.key?(failure)
222
+ raise Internal.invalid("Unknown failure #{failure.inspect}: use #{REQUEST_FAILURES.join(', ')}, an API error code or an exception")
223
+ end
224
+ unless retry_after.nil? || (retry_after.is_a?(Integer) && retry_after >= 0)
225
+ raise Internal.invalid('retry_after must be a whole number of seconds')
226
+ end
227
+ raise Internal.invalid('details must be a Hash') unless details.nil? || details.is_a?(Hash)
228
+
229
+ name = operation && operation.to_s.split('.').map { |part| Internal.camelize(part) }.join('.')
230
+ if name && !OPERATION_NAMES.include?(name)
231
+ raise Internal.invalid("Unknown operation #{operation.inspect}: use one of #{OPERATION_NAMES.join(', ')}")
232
+ end
233
+
234
+ @mock_lock.synchronize { @failures << { failure: failure, operation: name, details: details, retry_after: retry_after } }
235
+ self
66
236
  end
67
237
 
238
+ # @see ServerClient#ensure_lenses
239
+ def ensure_lenses(options) = operation('ensureLenses') { super }
240
+
241
+ # @see ServerClient#ensure_lens
242
+ def ensure_lens(options) = operation('ensureLens') { super }
243
+
68
244
  # Queues a JSON response for the next request.
69
245
  # @param status [Integer]
70
246
  # @param body [Object] encoded as JSON
@@ -93,18 +269,28 @@ module CalmLens
93
269
  end
94
270
 
95
271
  # Adds an event to the mock's events feed. Fields you omit (`id`, `projectId`, `createdAt`,
96
- # `severity`, `title`, `facts`) are filled in.
272
+ # `severity`, `title`, `facts`, and nil `description` and `assetId`) are filled in. An event
273
+ # with the `asset_id` of a stored asset carries that asset's snapshot as it is now, unless you
274
+ # pass `asset`.
97
275
  # @param event [Hash] at least `type:`; snake_case keys are converted.
98
276
  # @return [Hash] the stored event
99
277
  def enqueue_event(event)
100
- @mock_lock.synchronize { JSON.parse(JSON.generate(@fake.add_event(Internal.normalize(event)))) }
278
+ @mock_lock.synchronize { JSON.parse(JSON.generate(@fake.enqueue_event(Internal.normalize(event)))) }
101
279
  end
102
280
 
103
- # @return [Array<Request>] copies of every request received so far, oldest first.
281
+ # @return [Array<Request>] copies of every request received since the mock was created or
282
+ # {#clear_requests} last ran, oldest first.
104
283
  def requests
105
284
  @mock_lock.synchronize { @received.map { |request| Request.new(**Marshal.load(Marshal.dump(request.to_h))) } }
106
285
  end
107
286
 
287
+ # Forgets the requests recorded so far.
288
+ # @return [self]
289
+ def clear_requests
290
+ @mock_lock.synchronize { @received.clear }
291
+ self
292
+ end
293
+
108
294
  # @return [Integer] queued responses and errors not used yet.
109
295
  def remaining_responses
110
296
  @mock_lock.synchronize { @queue.size }
@@ -114,22 +300,79 @@ module CalmLens
114
300
 
115
301
  private
116
302
 
303
+ def not_found = APIError.class_for(404).new(ErrorCodes::ASSET_NOT_FOUND, 'Asset not found', status: 404)
304
+
305
+ # Wraps a resource's methods so requests know which call sent them, for {#fail_next_request}.
306
+ def track(resource, prefix, methods)
307
+ mock = self
308
+ resource.singleton_class.prepend(Module.new do
309
+ methods.each do |name|
310
+ define_method(name) do |*args, **options, &block|
311
+ mock.send(:operation, "#{prefix}.#{Internal.camelize(name)}") { super(*args, **options, &block) }
312
+ end
313
+ end
314
+ end)
315
+ end
316
+
317
+ # Runs a call; calls it makes (text.classify through assets.classify) count as the outer one.
318
+ def operation(name)
319
+ return yield if Thread.current[@call_key]
320
+
321
+ begin
322
+ Thread.current[@call_key] = { operation: name }
323
+ yield
324
+ ensure
325
+ Thread.current[@call_key] = nil
326
+ end
327
+ end
328
+
329
+ # The next queued failure for this request, which it uses up.
330
+ def failure_for(call)
331
+ index = @failures.index { |pending| pending[:operation].nil? || pending[:operation] == call&.dig(:operation) }
332
+ index && @failures.delete_at(index)
333
+ end
334
+
117
335
  # Returns the response rather than yielding it: the client accepts either from a transport.
118
336
  def receive(request, &)
119
337
  body = request.body.respond_to?(:read) ? read_stream(request.body) : request.body&.b
120
338
  path, query = Internal.split_url(request.url).last.split('?', 2)
121
339
  base = request.url.start_with?("#{cdn_base_url}/") ? URI(cdn_base_url).path : URI(base_url).path
340
+ call = Thread.current[@call_key]
122
341
  @mock_lock.synchronize do
123
- @received << Request.new(method: request.method, url: request.url, path: path.delete_prefix(base),
124
- query: URI.decode_www_form(query.to_s).to_h, headers: request.headers.dup,
125
- body: body)
126
- queued = @queue.shift
127
- raise queued if queued.is_a?(Exception)
128
-
129
- queued || @fake.handle(request.method, request.url, request.headers, body)
342
+ record = Request.new(method: request.method, url: request.url, path: decode_path(path.delete_prefix(base)),
343
+ query: URI.decode_www_form(query.to_s).to_h, headers: request.headers.dup, body: body)
344
+ @received << record
345
+ response = answer(request, body, failure_for(call))
346
+ record.status = response.status
347
+ response
130
348
  end
131
349
  end
132
350
 
351
+ def answer(request, body, failure)
352
+ return fail_request(failure) if failure
353
+
354
+ queued = @queue.shift
355
+ raise queued if queued.is_a?(Exception)
356
+
357
+ queued || @fake.handle(request.method, request.url, request.headers, body)
358
+ end
359
+
360
+ # Each segment of a request path, percent-decoded.
361
+ def decode_path(path)
362
+ path.split('/', -1).map do |segment|
363
+ segment.b.gsub(/%(\h\h)/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
364
+ end.join('/')
365
+ end
366
+
367
+ def fail_request(pending)
368
+ failure = pending[:failure]
369
+ raise failure if failure.is_a?(Exception)
370
+ raise Errno::ECONNRESET, 'Simulated network failure' if failure == 'transport-error'
371
+ raise Net::ReadTimeout, 'Simulated timeout' if failure == 'timeout'
372
+
373
+ @fake.error(failure, details: pending[:details] && JSON.parse(JSON.generate(pending[:details])), retry_after: pending[:retry_after])
374
+ end
375
+
133
376
  def read_stream(io)
134
377
  buffer = String.new(encoding: Encoding::BINARY)
135
378
  while (chunk = io.read(64 * 1024))