calmlens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,570 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'uri'
6
+
7
+ module CalmLens
8
+ module Testing
9
+ # Raised inside the fake API to answer with a CalmLens error response.
10
+ # @api private
11
+ class Failure < StandardError
12
+ attr_reader :code
13
+
14
+ def initialize(code, message = nil)
15
+ super(message || code)
16
+ @code = code
17
+ end
18
+ end
19
+
20
+ # In-memory records behind {MockServerClient}. Change them to arrange a test, such as setting an
21
+ # asset's `moderation.status`.
22
+ #
23
+ # @!attribute assets [Hash{String => Hash}] assets by ID, with API field names.
24
+ # @!attribute folders [Hash{String => Hash}] folders by ID.
25
+ # @!attribute content [Hash{String => Hash}] stored bytes by asset ID: `{'type', 'parts' => {n => bytes}}`.
26
+ # @!attribute events [Array<Hash>] the events feed, oldest first.
27
+ # @!attribute lenses [Hash{String => Hash}] lenses by identifier.
28
+ State = Struct.new(:assets, :folders, :content, :events, :lenses, :uploads, :idempotency, keyword_init: true)
29
+
30
+ # A stateful stand-in for the CalmLens API and CDN. It stores assets and folders, applies
31
+ # filters and ordering, serves stored bytes, and answers errors with the API's codes, statuses
32
+ # and body shape. It does not classify content, run transforms or index for semantic search.
33
+ # @api private
34
+ class FakeAPI
35
+ JSON_QUERY = %w[queue isFinal sizeInBytes totalSizeInBytes partNumber pageSize limit
36
+ processingOptions metadata].freeze
37
+ # Search match fields and where each one's value sits in the asset.
38
+ SEARCH_FIELDS = { 'name' => %w[name], 'id' => %w[id], 'externalId' => %w[externalId],
39
+ 'description' => %w[description], 'tags' => %w[tags], 'type' => %w[type],
40
+ 'status' => %w[moderation status] }.freeze
41
+ MIN_MULTIPART_IMAGE = 10 * 1024 * 1024
42
+ MAX_MULTIPART_IMAGE = 20 * 1024 * 1024
43
+
44
+ attr_reader :state
45
+
46
+ def initialize(project_id:, base_url:, cdn_base_url:, now:, create_id:, queued_outcome:, analyzed_outcome:)
47
+ @project_id = project_id
48
+ @base_url = base_url
49
+ @cdn_base_url = cdn_base_url
50
+ @now = now
51
+ @create_id = create_id
52
+ @queued_outcome = queued_outcome
53
+ @analyzed_outcome = analyzed_outcome
54
+ @state = State.new(assets: {}, folders: {}, content: {}, events: [], lenses: {}, uploads: {}, idempotency: {})
55
+ @event_sequence = 0
56
+ end
57
+
58
+ # Adds an event to the feed, filling the fields the API always sets.
59
+ def add_event(event)
60
+ @event_sequence += 1
61
+ time = @now.call
62
+ record = { 'id' => @create_id.call, 'projectId' => @project_id, 'severity' => 'low', 'facts' => [],
63
+ 'createdAt' => time, 'updatedAt' => time }.merge(event)
64
+ record['title'] ||= record['type']
65
+ @state.events << record.merge('_sequence' => @event_sequence)
66
+ record
67
+ end
68
+
69
+ # @return [Response]
70
+ def handle(method, url, headers, body)
71
+ if url.start_with?("#{@cdn_base_url}/")
72
+ serve(method, url.delete_prefix(@cdn_base_url))
73
+ else
74
+ api(method, url.delete_prefix(@base_url), headers, body)
75
+ end
76
+ rescue Failure => e
77
+ error(e.code, e.message)
78
+ end
79
+
80
+ private
81
+
82
+ def respond(status, data, headers = {})
83
+ Response.new(status: status, body: data.nil? ? '' : JSON.generate(data),
84
+ headers: { 'content-type' => 'application/json', 'x-request-id' => request_id }.merge(headers))
85
+ end
86
+
87
+ def request_id = "mock-#{SecureRandom.hex(8)}"
88
+
89
+ def error(code, message)
90
+ respond(ErrorCodes::STATUSES.fetch(code, 400), { 'code' => code, 'message' => message })
91
+ end
92
+
93
+ def fail!(code, message = nil) = raise(Failure.new(code, message))
94
+
95
+ def api(method, relative, headers, body)
96
+ uri = URI.parse(relative)
97
+ parts = uri.path.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
98
+ query = URI.decode_www_form(uri.query.to_s).to_h.to_h { |key, value| [key, query_value(key, value)] }
99
+ json = headers['Content-Type'] == 'application/json' && body ? JSON.parse(body) : {}
100
+ key = headers['Idempotency-Key']
101
+ upload = headers.find { |name, _| name.to_s.casecmp?('X-CalmLens-Upload') }&.last
102
+ return dispatch(method, parts, query, json, body, upload) unless key && method == 'POST'
103
+
104
+ # Like the API: a successful response is saved and replayed for the same key and request.
105
+ fingerprint = [method, relative, headers['Content-Type'], body&.bytesize]
106
+ saved = @state.idempotency[key]
107
+ if saved
108
+ fail!(ErrorCodes::IDEMPOTENCY_KEY_REUSED) unless saved[:fingerprint] == fingerprint
109
+ return Response.new(status: saved[:status], body: saved[:body],
110
+ headers: saved[:headers].merge('idempotent-replayed' => 'true', 'x-request-id' => request_id))
111
+ end
112
+ response = dispatch(method, parts, query, json, body, upload)
113
+ @state.idempotency[key] = { fingerprint: fingerprint, status: response.status, body: response.body,
114
+ headers: response.headers }
115
+ response
116
+ end
117
+
118
+ def query_value(key, value)
119
+ return value unless JSON_QUERY.include?(key)
120
+
121
+ JSON.parse(value)
122
+ rescue JSON::ParserError
123
+ fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, "#{key} is not valid JSON")
124
+ end
125
+
126
+ def dispatch(method, parts, query, json, body, upload)
127
+ data = case parts.first
128
+ when 'projects' then project(method, parts, query, json, body, upload)
129
+ when 'assets' then asset(method, parts, query, json, body)
130
+ when 'folders' then folder(method, parts, json)
131
+ else fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
132
+ end
133
+ data.is_a?(Response) ? data : respond(200, deep_copy(data))
134
+ end
135
+
136
+ def deep_copy(value) = JSON.parse(JSON.generate(value))
137
+
138
+ # `upload` is the X-CalmLens-Upload header, which picks how POST .../assets reads the
139
+ # request: absent is a JSON body, 'content' raw file content and 'part' a raw first part.
140
+ def project(method, parts, query, json, body, upload)
141
+ fail!(ErrorCodes::API_KEY_PROJECT_MISMATCH) unless parts[1] == @project_id
142
+ case [method, parts[2..], upload]
143
+ in ['GET', ['events'], _] then events(query)
144
+ in ['PUT', ['lenses'], _] then lenses(json.fetch('lenses', []))
145
+ in ['GET', ['folders'], _] then { 'folders' => project_folders.sort_by { |item| item['name'] } }
146
+ in ['POST', ['folders'], _] then create_folder(json)
147
+ in ['GET', ['assets', 'page'], _] then page(project_assets.reject { |item| item['parentId'] }, query)
148
+ in ['GET', ['assets', 'search'], _] then search(query)
149
+ in ['POST', ['assets'], nil] then create_asset(json, query['queue'] == true, url: json['url'])
150
+ in ['POST', ['assets'], 'content'] then create_asset(query, query['queue'] == true, data: body)
151
+ in ['POST', ['assets'], 'part'] then start_multipart(query, body)
152
+ else fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
153
+ end
154
+ end
155
+
156
+ def project_assets = @state.assets.values.select { |item| item['projectId'] == @project_id }
157
+ def project_folders = @state.folders.values.select { |item| item['projectId'] == @project_id }
158
+
159
+ def events(query)
160
+ # Without a cursor the feed starts from now, like the API.
161
+ return { 'events' => [], 'cursor' => "mock_#{@event_sequence}", 'hasMore' => false } unless query.key?('after')
162
+
163
+ match = query['after'].to_s.match(/\Amock_(\d+)\z/)
164
+ fail!(ErrorCodes::INVALID_EVENT_CURSOR) unless match
165
+
166
+ sequence = Integer(match[1], 10)
167
+ limit = query.fetch('limit', 50)
168
+ later = @state.events.select { |event| event['_sequence'] > sequence }
169
+ taken = later.first(limit)
170
+ cursor = taken.empty? ? sequence : taken.last['_sequence']
171
+ { 'events' => taken.map { |event| event.except('_sequence') }, 'cursor' => "mock_#{cursor}",
172
+ 'hasMore' => later.size > taken.size }
173
+ end
174
+
175
+ def lenses(definitions)
176
+ now = @now.call
177
+ pending = deep_copy(@state.lenses)
178
+ saved = definitions.map do |definition|
179
+ old = pending[definition['identifier']]
180
+ same = old && %w[name description type active transforms version].all? { |key| old[key] == definition[key] }
181
+ fail!(ErrorCodes::LENS_VERSION_CONFLICT) if old && !same && definition['version'].to_i <= old['version'].to_i
182
+
183
+ lens = if same
184
+ old
185
+ else
186
+ definition.merge('id' => old ? old['id'] : @create_id.call, 'projectId' => @project_id,
187
+ 'createdAt' => old ? old['createdAt'] : now, 'updatedAt' => now)
188
+ end
189
+ pending[lens['identifier']] = lens
190
+ end
191
+ @state.lenses = pending
192
+ { 'lenses' => saved }
193
+ end
194
+
195
+ def search(query)
196
+ text = query['query'].to_s.strip.downcase
197
+ fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'query is required') if text.empty?
198
+
199
+ items = project_assets.filter_map do |item|
200
+ fields = SEARCH_FIELDS.filter_map do |field, path|
201
+ field if Array(item.dig(*path)).any? { |value| value.to_s.downcase.include?(text) }
202
+ end
203
+ { 'asset' => item, 'score' => 1.0, 'matchedFields' => fields } unless fields.empty?
204
+ end
205
+ { 'query' => query['query'], 'items' => items.first(query.fetch('limit', 10)), 'semanticStatus' => 'disabled' }
206
+ end
207
+
208
+ def kind(mime, url)
209
+ return 'website' if url
210
+ return mime.split('/').first if mime.start_with?('image/', 'video/', 'audio/')
211
+ return 'plaintext-document' if mime.start_with?('text/')
212
+ return 'multimedia-document' if mime.include?('pdf') || mime.include?('document')
213
+
214
+ 'file'
215
+ end
216
+
217
+ def create_asset(options, queue, url: nil, data: nil, open: false)
218
+ if options['externalId'] && project_assets.any? { |item| item['externalId'] == options['externalId'] }
219
+ fail!(ErrorCodes::DUPLICATE_EXTERNAL_ID)
220
+ end
221
+ check_folder(options['folderId']) if options['folderId']
222
+ now = @now.call
223
+ mime = options['contentType'].to_s
224
+ asset = options.slice('name', 'description', 'metadata', 'externalId', 'externalUrl', 'processingOptions')
225
+ keep = options['processingOptions'].is_a?(Hash) && options['processingOptions']['keepContentAfterProcessing'] == true
226
+ asset.merge!('id' => @create_id.call, 'projectId' => @project_id, 'folderId' => options['folderId'],
227
+ 'parentId' => nil, 'type' => kind(mime, url), 'visibility' => options.fetch('visibility', 'private'),
228
+ 'createdAt' => now, 'updatedAt' => now,
229
+ **AssetRecord.sections(now: now, open: open, keep: keep, format: mime, size: data&.bytesize))
230
+ asset['externalUrl'] = url if url
231
+ @state.assets[asset['id']] = asset
232
+ @state.content[asset['id']] = { 'type' => mime, 'parts' => { 1 => data } } if data
233
+ open ? asset : finish(asset, queue)
234
+ end
235
+
236
+ # The queue mode and declared total are saved with the upload and apply to its final part.
237
+ def start_multipart(query, data)
238
+ total = query['totalSizeInBytes']
239
+ if query['contentType'].to_s.start_with?('image/') && [total, data.bytesize].compact.max > MAX_MULTIPART_IMAGE
240
+ fail!(ErrorCodes::MULTIPART_IMAGE_TOO_LARGE)
241
+ end
242
+
243
+ asset = create_asset(query, query['queue'] == true, data: data, open: true)
244
+ upload = { 'queue' => query['queue'] == true, 'total' => total }
245
+ return complete(asset, upload, nil) if query['isFinal']
246
+
247
+ @state.uploads[asset['id']] = upload
248
+ asset
249
+ end
250
+
251
+ # Applies queue mode: queued assets get `queued_outcome`; others `analyzed_outcome` now.
252
+ def finish(asset, queue)
253
+ return apply_outcome(asset, @queued_outcome) if queue
254
+
255
+ decision = @analyzed_outcome.respond_to?(:call) ? @analyzed_outcome.call(deep_copy(asset)) : @analyzed_outcome
256
+ return apply_outcome(asset, decision) unless decision.is_a?(Hash)
257
+
258
+ fields = deep_copy(decision)
259
+ AssetRecord.deep_merge!(apply_outcome(asset, AssetRecord.outcome_of(fields)), fields)
260
+ end
261
+
262
+ # Moves an asset to an outcome like the pipeline would: a decision finishes processing, and
263
+ # finished processing removes content kept only temporarily.
264
+ def apply_outcome(asset, outcome)
265
+ processing, moderation = AssetRecord.statuses(outcome)
266
+ now = @now.call
267
+ asset['processing'].merge!('status' => processing, 'updatedAt' => now)
268
+ asset['moderation'].merge!('status' => moderation, 'decidedAt' => processing == 'done' ? now : nil)
269
+ remove_content(asset, 'retention', now) if processing == 'done' && asset['content']['status'] == 'temporary'
270
+ asset['updatedAt'] = now
271
+ asset
272
+ end
273
+
274
+ def remove_content(asset, reason, now)
275
+ @state.content.delete(asset['id'])
276
+ asset['content'].merge!('status' => 'removed', 'removalReason' => reason, 'updatedAt' => now,
277
+ 'extractedText' => nil)
278
+ end
279
+
280
+ def complete(asset, upload, queue_override)
281
+ size = asset.dig('content', 'sizeInBytes').to_i
282
+ fail!(ErrorCodes::UPLOAD_SIZE_MISMATCH) if upload['total'] && upload['total'] != size
283
+ fail!(ErrorCodes::MULTIPART_IMAGE_TOO_SMALL) if (asset['type'] == 'image') && (size < MIN_MULTIPART_IMAGE)
284
+ finish(asset, queue_override.nil? ? upload['queue'] : queue_override == true)
285
+ end
286
+
287
+ def asset(method, parts, query, json, body)
288
+ id = parts[1]
289
+ item = @state.assets[id]
290
+ fail!(ErrorCodes::ASSET_NOT_FOUND) unless item && item['projectId'] == @project_id
291
+ case [method, parts[2]]
292
+ in ['GET', nil] then item
293
+ in ['PATCH', nil] then touch(item.merge!(json.except('id', 'projectId', 'processing', 'moderation', 'content')))
294
+ in ['DELETE', nil]
295
+ @state.assets.delete(id)
296
+ @state.content.delete(id)
297
+ {}
298
+ in ['DELETE', 'content']
299
+ remove_content(item, 'requested', @now.call) unless item['content']['status'] == 'removed'
300
+ touch(item)
301
+ in ['GET', 'children'] then page(project_assets.select { |child| child['parentId'] == id }, query)
302
+ in ['POST', 'retry']
303
+ fail!(ErrorCodes::ASSET_NOT_RETRYABLE) unless item['processing']['status'] == 'failed'
304
+ fail!(ErrorCodes::ASSET_CONTENT_UNAVAILABLE) if item['content']['status'] == 'removed'
305
+ # Like the API: back to processing, to be moderated again.
306
+ apply_outcome(item, 'pending')
307
+ in ['POST', 'move']
308
+ check_folder(json['folderId']) if json['folderId']
309
+ touch(item.merge!('folderId' => json['folderId']))
310
+ in ['POST', 'signed-url'] then signed_url(id, json)
311
+ in ['POST', 'part'] then part(item, query, body)
312
+ else fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
313
+ end
314
+ end
315
+
316
+ def touch(item)
317
+ item['updatedAt'] = @now.call
318
+ item
319
+ end
320
+
321
+ def signed_url(id, json)
322
+ expires = json.fetch('expiresInSeconds', 900)
323
+ params = URI.encode_www_form(json.slice('lens', 'transform').merge('expires' => expires))
324
+ { 'url' => "#{@cdn_base_url}/signed/#{URI.encode_www_form_component(id)}/#{json.fetch('resource', 'content')}?#{params}",
325
+ 'expiresAt' => @now.call + (expires * 1000) }
326
+ end
327
+
328
+ def part(item, query, body)
329
+ upload = @state.uploads[item['id']]
330
+ fail!(ErrorCodes::UPLOAD_NOT_OPEN) unless upload && item['processing']['status'] == 'uploading'
331
+ number = query['partNumber']
332
+ fail!(ErrorCodes::INVALID_UPLOAD_PART_NUMBER) unless number.is_a?(Integer) && number >= 2
333
+
334
+ stored = (@state.content[item['id']] ||= { 'type' => '', 'parts' => {} })
335
+ stored['parts'][number] = body.to_s
336
+ size = stored['parts'].values.sum(&:bytesize)
337
+ fail!(ErrorCodes::MULTIPART_IMAGE_TOO_LARGE) if item['type'] == 'image' && size > MAX_MULTIPART_IMAGE
338
+ item['content']['sizeInBytes'] = size
339
+ item['processing']['uploadPartsReceived'] = number
340
+ return touch(item) unless query['isFinal']
341
+
342
+ @state.uploads.delete(item['id'])
343
+ complete(item, upload, query['queue'])
344
+ end
345
+
346
+ def folder(method, parts, json)
347
+ id = parts[1]
348
+ item = check_folder(id)
349
+ case [method, parts[2]]
350
+ in ['GET', nil] then item
351
+ in ['PATCH', nil]
352
+ unique_name(json['name'], item['parentId'], id) if json.key?('name')
353
+ touch(item.merge!(json.slice('name')))
354
+ in ['POST', 'move'] then move_folder(item, json['parentId'])
355
+ in ['DELETE', nil]
356
+ if project_assets.any? { |asset| asset['folderId'] == id } || project_folders.any? { |other| other['parentId'] == id }
357
+ fail!(ErrorCodes::FOLDER_NOT_EMPTY)
358
+ end
359
+
360
+ @state.folders.delete(id)
361
+ nil
362
+ else fail!(ErrorCodes::FOLDER_NOT_FOUND, 'Not found')
363
+ end
364
+ end
365
+
366
+ def check_folder(id)
367
+ item = @state.folders[id]
368
+ fail!(ErrorCodes::FOLDER_NOT_FOUND) unless item && item['projectId'] == @project_id
369
+ item
370
+ end
371
+
372
+ def unique_name(name, parent_id, exclude = nil)
373
+ return unless project_folders.any? { |other| other['id'] != exclude && other['name'] == name && other['parentId'] == parent_id }
374
+
375
+ fail!(ErrorCodes::DUPLICATE_FOLDER_NAME)
376
+ end
377
+
378
+ def create_folder(json)
379
+ check_folder(json['parentId']) if json['parentId']
380
+ unique_name(json['name'], json['parentId'])
381
+ now = @now.call
382
+ item = json.slice('name').merge('id' => @create_id.call, 'projectId' => @project_id,
383
+ 'parentId' => json['parentId'], 'createdAt' => now, 'updatedAt' => now)
384
+ @state.folders[item['id']] = item
385
+ end
386
+
387
+ def move_folder(item, parent_id)
388
+ current = parent_id
389
+ visited = [item['id']]
390
+ while current
391
+ fail!(ErrorCodes::FOLDER_PARENT_CYCLE) if visited.include?(current)
392
+
393
+ visited << current
394
+ current = check_folder(current)['parentId']
395
+ end
396
+ unique_name(item['name'], parent_id, item['id'])
397
+ touch(item.merge!('parentId' => parent_id))
398
+ end
399
+
400
+ def page(items, query)
401
+ size = query.fetch('pageSize', 10)
402
+ ordering = ordering(query)
403
+ fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD) if ordering.any? { |o| !%w[createdAt id].include?(o['field']) }
404
+ ordering << { 'field' => 'id', 'direction' => ordering.first['direction'] } unless ordering.any? { |o| o['field'] == 'id' }
405
+ matched = RSQL.filter(items, query['filter'])
406
+ sorted = matched.sort { |a, b| compare(a, b, ordering) }
407
+ if query['cursor']
408
+ begin
409
+ boundary = JSON.parse(query['cursor'].unpack1('m0'))
410
+ valid = boundary.is_a?(Hash) && ordering.all? do |o|
411
+ o['field'] == 'id' ? boundary[o['field']].is_a?(String) : boundary[o['field']].is_a?(Numeric)
412
+ end
413
+ fail!(ErrorCodes::INVALID_CURSOR) unless valid
414
+ rescue ArgumentError, JSON::ParserError
415
+ fail!(ErrorCodes::INVALID_CURSOR)
416
+ end
417
+ sorted = sorted.select { |item| compare(item, boundary, ordering).positive? }
418
+ end
419
+ selected = sorted.first(size)
420
+ more = sorted.size > size
421
+ cursor = more ? [JSON.generate(selected.last.slice(*ordering.map { |o| o['field'] }))].pack('m0') : nil
422
+ { 'items' => selected, 'nextCursor' => cursor,
423
+ 'pageSize' => size, 'ordering' => ordering, 'hasMore' => more }
424
+ end
425
+
426
+ def ordering(query)
427
+ entries = query.keys.filter_map { |key| key.match(/\Aordering\[(\d+)\]\[(field|direction)\]\z/) }
428
+ return [{ 'field' => 'createdAt', 'direction' => 'DESC' }] if entries.empty?
429
+
430
+ entries.group_by { |match| Integer(match[1], 10) }.sort.map do |_, matches|
431
+ matches.to_h { |match| [match[2], query[match[0]]] }
432
+ end
433
+ end
434
+
435
+ def compare(left, right, ordering)
436
+ ordering.each do |order|
437
+ a = left[order['field']]
438
+ b = right[order['field']]
439
+ # Missing values sort last in either direction.
440
+ result = if a.nil? || b.nil? then (a.nil? ? 1 : 0) - (b.nil? ? 1 : 0)
441
+ else
442
+ natural = (a <=> b) || (a.to_s <=> b.to_s)
443
+ order['direction'].to_s.casecmp?('DESC') ? -natural : natural
444
+ end
445
+ return result unless result.zero?
446
+ end
447
+ left['id'].to_s <=> right['id'].to_s
448
+ end
449
+
450
+ def serve(method, relative)
451
+ segments = relative.split(/[?#]/, 2).first.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
452
+ _, project, identifier, resource = segments
453
+ return text(404, 'Asset not found') unless project == @project_id
454
+
455
+ item = project_assets.find { |asset| asset['id'] == identifier || asset['externalId'] == identifier }
456
+ return text(404, 'Asset not found') unless item && %w[content preview].include?(resource)
457
+ return text(403, 'Asset is held') if item['moderation'].values_at('status', 'action') == %w[rejected hide]
458
+
459
+ stored = @state.content[item['id']]
460
+ return text(404, 'Asset content not found') unless stored
461
+
462
+ bytes = stored['parts'].sort.map(&:last).join.b
463
+ Response.new(status: 200, body: method == 'HEAD' ? '' : bytes,
464
+ headers: { 'content-type' => stored['type'], 'content-length' => bytes.bytesize.to_s,
465
+ 'x-request-id' => request_id })
466
+ end
467
+
468
+ def text(status, message)
469
+ Response.new(status: status, body: message,
470
+ headers: { 'content-type' => 'text/plain; charset=utf-8', 'x-request-id' => request_id })
471
+ end
472
+ end
473
+
474
+ # The nested state of a new mock asset, and merging test fields into it.
475
+ # @api private
476
+ module AssetRecord
477
+ module_function
478
+
479
+ # Processing and moderation status for each outcome.
480
+ OUTCOMES = { 'pending' => %w[processing pending], 'approved' => %w[done approved],
481
+ 'rejected' => %w[done rejected], 'failed' => %w[failed pending] }.freeze
482
+
483
+ def statuses(outcome)
484
+ OUTCOMES.fetch(outcome) do
485
+ raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
486
+ "MockServerClient outcomes are #{OUTCOMES.keys.join(', ')} (got #{outcome.inspect})")
487
+ end
488
+ end
489
+
490
+ # The outcome of analyzed fields: `failed` when they fail processing, else their moderation status.
491
+ def outcome_of(fields)
492
+ fields.dig('processing', 'status') == 'failed' ? 'failed' : fields.dig('moderation', 'status') || 'approved'
493
+ end
494
+
495
+ def sections(now:, open:, keep:, format:, size:)
496
+ {
497
+ 'processing' => { 'status' => open ? 'uploading' : 'processing', 'updatedAt' => now,
498
+ 'uploadPartsReceived' => open ? 1 : nil },
499
+ 'moderation' => { 'status' => 'pending', 'action' => nil, 'note' => nil, 'reviewerAction' => nil,
500
+ 'reviewerNote' => nil, 'reviewerUserId' => nil, 'reviewedAt' => nil, 'decidedAt' => nil,
501
+ 'report' => nil },
502
+ 'content' => { 'status' => keep ? 'stored' : 'temporary', 'removalReason' => nil, 'updatedAt' => now,
503
+ 'safetyHold' => nil, 'format' => format.empty? ? nil : format, 'extension' => nil,
504
+ 'sizeInBytes' => size, 'checksums' => nil, 'image' => nil, 'previewImage' => nil,
505
+ 'extractedText' => nil }
506
+ }
507
+ end
508
+
509
+ def deep_merge!(target, fields)
510
+ fields.each do |key, value|
511
+ if value.is_a?(Hash) && target[key].is_a?(Hash)
512
+ deep_merge!(target[key], value)
513
+ else
514
+ target[key] = value
515
+ end
516
+ end
517
+ target
518
+ end
519
+ end
520
+
521
+ # The RSQL subset the fake API understands: `field==value` and `field!=value` (with `*`
522
+ # wildcards and quoted values), combined with `;` (and) and `,` (or). Anything else raises
523
+ # instead of being ignored, so a test never passes on an unfiltered list.
524
+ # @api private
525
+ module RSQL
526
+ module_function
527
+
528
+ def filter(items, expression)
529
+ return items if expression.nil? || expression.to_s.strip.empty?
530
+
531
+ groups = split(expression.to_s, ',').map { |group| split(group, ';').map { |term| comparison(term) } }
532
+ items.select { |item| groups.any? { |terms| terms.all? { |test| test.call(item) } } }
533
+ end
534
+
535
+ def split(text, separator)
536
+ parts = ['']
537
+ quote = nil
538
+ text.each_char do |char|
539
+ if quote
540
+ quote = nil if char == quote
541
+ elsif ['"', "'"].include?(char)
542
+ quote = char
543
+ elsif char == separator
544
+ parts << ''
545
+ next
546
+ end
547
+ parts[-1] += char
548
+ end
549
+ parts
550
+ end
551
+
552
+ def comparison(term)
553
+ match = term.strip.match(/\A([A-Za-z][\w.]*)(==|!=)(.+)\z/m)
554
+ unless match
555
+ raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
556
+ "MockServerClient filters support only ==, != with ; and , (got #{term.inspect})")
557
+ end
558
+
559
+ field, operator, raw = match.captures
560
+ value = raw.strip.match?(/\A(["']).*\1\z/m) ? raw.strip[1..-2] : raw.strip
561
+ pattern = Regexp.new("\\A#{Regexp.escape(value).gsub('\*', '.*')}\\z", Regexp::MULTILINE)
562
+ lambda do |item|
563
+ actual = field.split('.').reduce(item) { |object, key| object.is_a?(Hash) ? object[key] : nil }
564
+ equal = Array(actual.nil? ? 'null' : actual).any? { |entry| pattern.match?(entry.to_s) }
565
+ operator == '==' ? equal : !equal
566
+ end
567
+ end
568
+ end
569
+ end
570
+ end