overseer-testing-control-rails 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,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+
5
+ module Overseer
6
+ module TestingControl
7
+ module Rails
8
+ class ConfigurationError < StandardError; end
9
+
10
+ class Configuration
11
+ State = Data.define(:id, :version, :title, :description, :idempotency, :input_schema, :output_schema, :handler)
12
+ Probe = Data.define(:id, :version, :title, :description, :input_schema, :output_schema, :handler)
13
+ Sink = Data.define(
14
+ :id, :version, :title, :description, :effect_kinds, :outcomes, :query_input_schema,
15
+ :record_schema, :query_matcher
16
+ )
17
+ HandlerContext = Data.define(:run_id, :correlation_id, :step_id, :idempotency_key)
18
+ FEATURES = %w[
19
+ correlation-propagation
20
+ deterministic-replay
21
+ reset
22
+ schema-validation
23
+ sink-clearing
24
+ ].freeze
25
+ RESET_STRATEGIES = %w[in-process runtime-restart].freeze
26
+ IDEMPOTENCY = %w[required supported none].freeze
27
+ EFFECT_KINDS = %w[email sms webhook document-delivery integration-command custom].freeze
28
+ DEFAULT_SOURCE_PROVIDER = -> { ENV.fetch('OVERSEER_TESTING_CONTROL_APPLICATION_VERSION', '') }
29
+ DEFAULT_ENVIRONMENT_PROVIDER = -> { ENV.fetch('OVERSEER_TESTING_CONTROL_ENVIRONMENT_ID', '') }
30
+ DEFAULT_LIMITS = {
31
+ 'maxRequestBytes' => 65_536,
32
+ 'maxResponseBytes' => 262_144,
33
+ 'maxSchemaBytes' => 65_536,
34
+ 'maxSinkRecordsPerQuery' => 25,
35
+ 'maxSinkRecordBytes' => 16_384
36
+ }.freeze
37
+ LIMIT_RANGES = {
38
+ 'maxRequestBytes' => (1_024..16_777_216),
39
+ 'maxResponseBytes' => (1_024..16_777_216),
40
+ 'maxSchemaBytes' => (1_024..1_048_576),
41
+ 'maxSinkRecordsPerQuery' => (1..1_000),
42
+ 'maxSinkRecordBytes' => (256..1_048_576)
43
+ }.freeze
44
+
45
+ attr_reader :states, :probes, :sinks, :idempotency_store, :sink_store, :reset_strategy,
46
+ :reset_handler, :max_sink_records, :limits, :request_lock
47
+
48
+ def initialize
49
+ @request_lock = Monitor.new
50
+ @identity = {
51
+ application: nil,
52
+ source: DEFAULT_SOURCE_PROVIDER,
53
+ environment: DEFAULT_ENVIRONMENT_PROVIDER,
54
+ kind: 'test',
55
+ isolated: true
56
+ }
57
+ @reset_strategy = 'runtime-restart'
58
+ @reset_handler = nil
59
+ @states = []
60
+ @probes = []
61
+ @sinks = []
62
+ @limits = DEFAULT_LIMITS
63
+ @idempotency_limit = 128
64
+ @max_sink_records = 250
65
+ rebuild_stores!
66
+ @finalized = false
67
+ end
68
+
69
+ def identity(
70
+ application:,
71
+ source: DEFAULT_SOURCE_PROVIDER,
72
+ environment: DEFAULT_ENVIRONMENT_PROVIDER,
73
+ kind: 'test',
74
+ isolated: true
75
+ )
76
+ mutable!
77
+ @identity = { application:, source:, environment:, kind:, isolated: }
78
+ end
79
+
80
+ def reset(strategy:, handler: nil)
81
+ mutable!
82
+ @reset_strategy = strategy
83
+ @reset_handler = handler
84
+ end
85
+
86
+ def bounds(
87
+ max_request_bytes: limits.fetch('maxRequestBytes'),
88
+ max_response_bytes: limits.fetch('maxResponseBytes'),
89
+ max_schema_bytes: limits.fetch('maxSchemaBytes'),
90
+ max_sink_records_per_query: limits.fetch('maxSinkRecordsPerQuery'),
91
+ max_sink_record_bytes: limits.fetch('maxSinkRecordBytes'),
92
+ idempotency_entries: @idempotency_limit,
93
+ sink_records: max_sink_records
94
+ )
95
+ mutable!
96
+ candidate = {
97
+ 'maxRequestBytes' => max_request_bytes,
98
+ 'maxResponseBytes' => max_response_bytes,
99
+ 'maxSchemaBytes' => max_schema_bytes,
100
+ 'maxSinkRecordsPerQuery' => max_sink_records_per_query,
101
+ 'maxSinkRecordBytes' => max_sink_record_bytes
102
+ }
103
+ validate_limits!(candidate, idempotency_entries:, sink_records:)
104
+ @limits = candidate.freeze
105
+ @idempotency_limit = idempotency_entries
106
+ @max_sink_records = sink_records
107
+ rebuild_stores!
108
+ end
109
+
110
+ def register_state(id:, version:, title:, idempotency:, input_schema:, output_schema:, handler:,
111
+ description: nil)
112
+ mutable!
113
+ @states << State.new(
114
+ id:, version:, title:, description:, idempotency:, input_schema:, output_schema:, handler:
115
+ )
116
+ end
117
+
118
+ def register_probe(id:, version:, title:, input_schema:, output_schema:, handler:, description: nil)
119
+ mutable!
120
+ @probes << Probe.new(id:, version:, title:, description:, input_schema:, output_schema:, handler:)
121
+ end
122
+
123
+ def register_sink(
124
+ id:, version:, title:, effect_kinds:, outcomes:, query_input_schema:, record_schema:,
125
+ description: nil, query_matcher: nil
126
+ )
127
+ mutable!
128
+ @sinks << Sink.new(
129
+ id:, version:, title:, description:, effect_kinds:, outcomes:, query_input_schema:,
130
+ record_schema:, query_matcher:
131
+ )
132
+ end
133
+
134
+ def finalize!
135
+ return self if @finalized
136
+
137
+ validate!
138
+ @states.freeze
139
+ @probes.freeze
140
+ @sinks.freeze
141
+ @finalized = true
142
+ self
143
+ rescue ProtocolV3::ValidationError => e
144
+ raise ConfigurationError, e.message
145
+ end
146
+
147
+ def runtime_identity_valid?
148
+ validate_identity!
149
+ true
150
+ rescue ConfigurationError, ProtocolV3::ValidationError, KeyError, TypeError
151
+ false
152
+ end
153
+
154
+ def application_id
155
+ resolve_identity(:application)
156
+ end
157
+
158
+ def environment_id
159
+ resolve_identity(:environment)
160
+ end
161
+
162
+ def application_version
163
+ resolve_identity(:source)
164
+ end
165
+
166
+ def environment_kind
167
+ resolve_identity(:kind)
168
+ end
169
+
170
+ def environment_isolated
171
+ resolve_identity(:isolated)
172
+ end
173
+
174
+ def capabilities_data
175
+ finalize!
176
+ {
177
+ 'application' => { 'id' => application_id, 'version' => application_version },
178
+ 'environment' => {
179
+ 'id' => environment_id,
180
+ 'kind' => environment_kind,
181
+ 'isolated' => environment_isolated
182
+ },
183
+ 'reset' => { 'strategy' => reset_strategy, 'clearsSinks' => true },
184
+ 'features' => FEATURES,
185
+ 'limits' => limits,
186
+ 'safety' => {
187
+ 'syntheticDataRequired' => true,
188
+ 'realCredentialsAllowed' => false,
189
+ 'secretPersistenceAllowed' => false,
190
+ 'externalNetwork' => 'blocked'
191
+ },
192
+ 'states' => states.map { |state| state_document(state) },
193
+ 'probes' => probes.map { |probe| probe_document(probe) },
194
+ 'sinks' => sinks.map { |sink| sink_document(sink) }
195
+ }
196
+ end
197
+
198
+ def state!(id, version)
199
+ states.find { |item| item.id == id && item.version == version } ||
200
+ raise(ProtocolV3::ValidationError, "state capability #{id}@#{version} is unavailable")
201
+ end
202
+
203
+ def probe!(id, version)
204
+ probes.find { |item| item.id == id && item.version == version } ||
205
+ raise(ProtocolV3::ValidationError, "probe capability #{id}@#{version} is unavailable")
206
+ end
207
+
208
+ def sink!(id, version)
209
+ sinks.find { |item| item.id == id && item.version == version } ||
210
+ raise(ProtocolV3::ValidationError, "sink capability #{id}@#{version} is unavailable")
211
+ end
212
+
213
+ def clear_stores!
214
+ idempotency_store.clear
215
+ sink_store.clear
216
+ end
217
+
218
+ private
219
+
220
+ def mutable!
221
+ return unless @finalized
222
+
223
+ raise ConfigurationError,
224
+ 'Overseer Rails testing-control adapter configuration is already active'
225
+ end
226
+
227
+ def validate!
228
+ validate_identity!
229
+ raise ConfigurationError, 'reset strategy is invalid' unless RESET_STRATEGIES.include?(reset_strategy)
230
+ raise ConfigurationError, 'in-process reset requires a reset handler' if
231
+ reset_strategy == 'in-process' && !reset_handler.respond_to?(:call)
232
+
233
+ validate_limits!(limits, idempotency_entries: @idempotency_limit, sink_records: max_sink_records)
234
+
235
+ validate_family!(states, 'state') { |item| validate_state!(item) }
236
+ validate_family!(probes, 'probe') { |item| validate_probe!(item) }
237
+ validate_family!(sinks, 'sink') { |item| validate_sink!(item) }
238
+ end
239
+
240
+ def validate_family!(items, kind)
241
+ seen = {}
242
+ items.each do |item|
243
+ key = "#{item.id}@#{item.version}"
244
+ raise ConfigurationError, "duplicate #{kind} capability #{key}" if seen[key]
245
+
246
+ seen[key] = true
247
+ validate_common!(item, kind)
248
+ yield(item)
249
+ end
250
+ end
251
+
252
+ def validate_common!(item, kind)
253
+ ProtocolV3.capability_id!(item.id, label: "#{kind} ID")
254
+ ProtocolV3.capability_version!(item.version, label: "#{kind} version")
255
+ raise ConfigurationError, "#{kind} title is invalid" unless
256
+ item.title.is_a?(String) && item.title.bytesize.between?(1, 160)
257
+ raise ConfigurationError, "#{kind} description is invalid" if
258
+ item.description && (!item.description.is_a?(String) || item.description.bytesize > 1000)
259
+ end
260
+
261
+ def validate_state!(state)
262
+ unless IDEMPOTENCY.include?(state.idempotency)
263
+ raise ConfigurationError,
264
+ 'state idempotency declaration is invalid'
265
+ end
266
+ raise ConfigurationError, 'state handler must be callable' unless state.handler.respond_to?(:call)
267
+
268
+ validate_schema_pair!(state, :input_schema, :output_schema, label: "state #{state.id}@#{state.version}")
269
+ end
270
+
271
+ def validate_probe!(probe)
272
+ raise ConfigurationError, 'probe handler must be callable' unless probe.handler.respond_to?(:call)
273
+
274
+ validate_schema_pair!(probe, :input_schema, :output_schema, label: "probe #{probe.id}@#{probe.version}")
275
+ end
276
+
277
+ def validate_sink!(sink)
278
+ valid_kinds = sink.effect_kinds.is_a?(Array) && !sink.effect_kinds.empty? &&
279
+ sink.effect_kinds.uniq == sink.effect_kinds &&
280
+ sink.effect_kinds.all? { |kind| EFFECT_KINDS.include?(kind) }
281
+ raise ConfigurationError, 'sink effect kinds are invalid' unless valid_kinds
282
+
283
+ valid_outcomes = sink.outcomes.is_a?(Array) && !sink.outcomes.empty? &&
284
+ sink.outcomes.uniq == sink.outcomes
285
+ raise ConfigurationError, 'sink outcomes are invalid' unless valid_outcomes
286
+
287
+ sink.outcomes.each { |outcome| ProtocolV3.capability_id!(outcome, label: 'sink outcome') }
288
+ validate_schema_pair!(sink, :query_input_schema, :record_schema, label: "sink #{sink.id}@#{sink.version}")
289
+ end
290
+
291
+ def validate_schema_pair!(item, *names, label:)
292
+ names.each do |name|
293
+ ProtocolV3.validate_schema!(
294
+ item.public_send(name),
295
+ label: "#{label} #{name}",
296
+ max_bytes: limits.fetch('maxSchemaBytes')
297
+ )
298
+ end
299
+ end
300
+
301
+ def validate_identity!
302
+ ProtocolV3.capability_id!(application_id, label: 'application ID')
303
+ ProtocolV3.application_version!(application_version)
304
+ ProtocolV3.identifier!(environment_id, label: 'environment ID')
305
+ raise ConfigurationError, 'environment kind must be test' unless environment_kind == 'test'
306
+ raise ConfigurationError, 'environment isolation witness must be true' unless environment_isolated == true
307
+ end
308
+
309
+ def validate_limits!(candidate, idempotency_entries:, sink_records:)
310
+ LIMIT_RANGES.each do |name, range|
311
+ value = candidate.fetch(name)
312
+ raise ConfigurationError, "#{name} is outside the protocol v3 range" unless
313
+ value.is_a?(Integer) && range.cover?(value)
314
+ end
315
+ raise ConfigurationError, 'idempotency entry limit must be between 1 and 1000' unless
316
+ idempotency_entries.is_a?(Integer) && idempotency_entries.between?(1, 1_000)
317
+ raise ConfigurationError, 'fake-sink record limit must be between 1 and 1000' unless
318
+ sink_records.is_a?(Integer) && sink_records.between?(1, 1_000)
319
+ end
320
+
321
+ def resolve_identity(name)
322
+ value = @identity.fetch(name)
323
+ value.respond_to?(:call) ? value.call : value
324
+ end
325
+
326
+ def rebuild_stores!
327
+ @idempotency_store = IdempotencyStore.new(limit: @idempotency_limit)
328
+ @sink_store = SinkStore.new(self)
329
+ end
330
+
331
+ def state_document(state)
332
+ {
333
+ 'id' => state.id,
334
+ 'version' => state.version,
335
+ 'title' => state.title,
336
+ **(state.description ? { 'description' => state.description } : {}),
337
+ 'idempotency' => state.idempotency,
338
+ 'inputSchema' => state.input_schema,
339
+ 'outputSchema' => state.output_schema
340
+ }
341
+ end
342
+
343
+ def probe_document(probe)
344
+ {
345
+ 'id' => probe.id,
346
+ 'version' => probe.version,
347
+ 'title' => probe.title,
348
+ **(probe.description ? { 'description' => probe.description } : {}),
349
+ 'readOnly' => true,
350
+ 'inputSchema' => probe.input_schema,
351
+ 'outputSchema' => probe.output_schema
352
+ }
353
+ end
354
+
355
+ def sink_document(sink)
356
+ {
357
+ 'id' => sink.id,
358
+ 'version' => sink.version,
359
+ 'title' => sink.title,
360
+ **(sink.description ? { 'description' => sink.description } : {}),
361
+ 'effectKinds' => sink.effect_kinds,
362
+ 'outcomes' => sink.outcomes,
363
+ 'queryInputSchema' => sink.query_input_schema,
364
+ 'recordSchema' => sink.record_schema
365
+ }
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module Rails
6
+ class CorrelationMiddleware
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(environment)
12
+ return @app.call(environment) unless Overseer::TestingControl::Rails.active?
13
+
14
+ # Memory stores belong to one process. Serialize its public and control
15
+ # requests so reset cannot race a mutation or capture in that process.
16
+ Overseer::TestingControl::Rails.configuration.request_lock.synchronize do
17
+ correlated_call(environment)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def correlated_call(environment)
24
+ context = context_from(environment)
25
+ RequestContext.with(context) do
26
+ status, headers, body = @app.call(environment)
27
+ headers[ProtocolV3::HEADERS.fetch(:correlation_id)] = context.correlation_id if context&.correlation_id
28
+ [status, headers, body]
29
+ end
30
+ end
31
+
32
+ def context_from(environment)
33
+ correlation_id = header(environment, :correlation_id)
34
+ return unless valid_identifier?(correlation_id)
35
+
36
+ run_id = header(environment, :run_id)
37
+ step_id = header(environment, :step_id)
38
+ RequestContext::Context.new(
39
+ run_id: valid_identifier?(run_id) ? run_id : nil,
40
+ correlation_id:,
41
+ step_id: valid_identifier?(step_id) ? step_id : nil
42
+ )
43
+ end
44
+
45
+ def header(environment, name)
46
+ rack_name = "HTTP_#{ProtocolV3::HEADERS.fetch(name).upcase.tr('-', '_')}"
47
+ environment[rack_name]
48
+ end
49
+
50
+ def valid_identifier?(value)
51
+ value.is_a?(String) && value.match?(ProtocolV3::IDENTIFIER_PATTERN)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller/railtie'
4
+
5
+ module Overseer
6
+ module TestingControl
7
+ module Rails
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace Overseer::TestingControl::Rails
10
+
11
+ initializer 'overseer-testing-control-rails.correlation-middleware' do |application|
12
+ application.middleware.insert_before(0, CorrelationMiddleware)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'overseer/testing_control/protocol_v3'
4
+
5
+ module Overseer
6
+ module TestingControl
7
+ module Rails
8
+ ProtocolV3 = ::Overseer::TestingControl::ProtocolV3
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module Rails
6
+ module RequestContext
7
+ Context = Data.define(:run_id, :correlation_id, :step_id)
8
+ THREAD_KEY = :overseer_testing_control_rails_request_context
9
+
10
+ module_function
11
+
12
+ def current
13
+ Thread.current[THREAD_KEY]
14
+ end
15
+
16
+ def with(context)
17
+ previous = current
18
+ Thread.current[THREAD_KEY] = context
19
+ yield
20
+ ensure
21
+ Thread.current[THREAD_KEY] = previous
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'securerandom'
5
+
6
+ module Overseer
7
+ module TestingControl
8
+ module Rails
9
+ class CaptureError < StandardError; end
10
+ class IdempotencyConflict < StandardError; end
11
+ class StoreLimitExceeded < StandardError; end
12
+
13
+ class IdempotencyStore
14
+ Entry = Data.define(:digest, :value)
15
+
16
+ def initialize(limit:)
17
+ @limit = limit
18
+ @entries = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def fetch_or_store(key:, digest:)
23
+ @mutex.synchronize do
24
+ existing = @entries[key]
25
+ if existing
26
+ raise IdempotencyConflict, 'Idempotency key was reused with different input' unless
27
+ existing.digest == digest
28
+
29
+ return deep_copy(existing.value)
30
+ end
31
+ raise StoreLimitExceeded, 'Idempotency storage reached its bounded limit' if @entries.length >= @limit
32
+
33
+ value = yield
34
+ @entries[key] = Entry.new(digest:, value: deep_copy(value))
35
+ deep_copy(value)
36
+ end
37
+ end
38
+
39
+ def clear
40
+ @mutex.synchronize { @entries.clear }
41
+ end
42
+
43
+ private
44
+
45
+ def deep_copy(value)
46
+ JSON.parse(JSON.generate(value))
47
+ end
48
+ end
49
+
50
+ class SinkStore
51
+ def initialize(configuration)
52
+ @configuration = configuration
53
+ @records = []
54
+ @mutex = Mutex.new
55
+ end
56
+
57
+ def capture(capability:, version:, effect_kind:, summary:, payload:, context:, outcome: nil, metadata: nil)
58
+ definition = @configuration.sink!(capability, version)
59
+ ProtocolV3.identifier!(context.run_id, label: 'capture run ID')
60
+ ProtocolV3.identifier!(context.correlation_id, label: 'capture correlation ID')
61
+ ProtocolV3.identifier!(context.step_id, label: 'capture step ID') if context.step_id
62
+ validate_capture!(definition, effect_kind:, outcome:, summary:, payload:, metadata:)
63
+ record = {
64
+ 'id' => "effect-#{SecureRandom.hex(12)}",
65
+ 'capability' => { 'id' => capability, 'version' => version },
66
+ 'effectKind' => effect_kind,
67
+ **(outcome ? { 'outcome' => outcome } : {}),
68
+ 'correlationId' => context.correlation_id,
69
+ **(context.step_id ? { 'stepId' => context.step_id } : {}),
70
+ 'capturedAt' => ProtocolV3.timestamp,
71
+ 'summary' => summary,
72
+ **(metadata ? { 'metadata' => ProtocolV3.stringify(metadata) } : {}),
73
+ 'payload' => ProtocolV3.stringify(payload)
74
+ }
75
+ validate_record_size!(record)
76
+ @mutex.synchronize do
77
+ raise StoreLimitExceeded, 'Fake-sink storage reached its bounded limit' if
78
+ @records.length >= @configuration.max_sink_records
79
+
80
+ @records << { run_id: context.run_id, record: deep_copy(record) }
81
+ end
82
+ deep_copy(record)
83
+ rescue ProtocolV3::ValidationError => e
84
+ raise CaptureError, e.message
85
+ end
86
+
87
+ def query(capability:, version:, run_id:, input:, filter:, cursor:, limit:)
88
+ definition = @configuration.sink!(capability, version)
89
+ ProtocolV3.identifier!(run_id, label: 'sink query run ID')
90
+ ProtocolV3.validate_value!(definition.query_input_schema, input, label: 'sink query input')
91
+ if !input.empty? && !definition.query_matcher
92
+ raise ProtocolV3::ValidationError, 'non-empty sink input requires a product query matcher'
93
+ end
94
+
95
+ offset = cursor ? Integer(cursor, 10) : 0
96
+ raise ProtocolV3::ValidationError, 'sink cursor is outside the bounded result' if offset.negative?
97
+
98
+ matched = @mutex.synchronize do
99
+ selected = @records.filter_map do |entry|
100
+ next unless entry.fetch(:run_id) == run_id
101
+
102
+ record = entry.fetch(:record)
103
+ next unless matches_filter?(record, capability, version, filter)
104
+ next unless matches_input?(definition, record, input)
105
+
106
+ record
107
+ end
108
+ selected.map { |record| deep_copy(record) }
109
+ end
110
+ records = matched.slice(offset, limit) || []
111
+ next_offset = offset + records.length
112
+ [records, next_offset < matched.length ? next_offset.to_s : nil]
113
+ rescue ArgumentError
114
+ raise ProtocolV3::ValidationError, 'sink cursor is invalid'
115
+ end
116
+
117
+ def clear
118
+ @mutex.synchronize { @records.clear }
119
+ end
120
+
121
+ private
122
+
123
+ def matches_filter?(record, capability, version, filter)
124
+ record.dig('capability', 'id') == capability &&
125
+ record.dig('capability', 'version') == version &&
126
+ record['correlationId'] == filter.fetch('correlationId') &&
127
+ (!filter['stepId'] || record['stepId'] == filter['stepId']) &&
128
+ (!filter['effectKinds'] || filter['effectKinds'].include?(record['effectKind'])) &&
129
+ (!filter['capturedAfter'] || Time.iso8601(record['capturedAt']) > Time.iso8601(filter['capturedAfter']))
130
+ end
131
+
132
+ def matches_input?(definition, record, input)
133
+ !definition.query_matcher || definition.query_matcher.call(deep_copy(record.fetch('payload')),
134
+ deep_copy(input))
135
+ end
136
+
137
+ def validate_capture!(definition, effect_kind:, outcome:, summary:, payload:, metadata:)
138
+ raise ProtocolV3::ValidationError, 'fake effect kind is not registered for the sink' unless
139
+ definition.effect_kinds.include?(effect_kind)
140
+ raise ProtocolV3::ValidationError, 'fake effect outcome is not registered for the sink' if
141
+ outcome && !definition.outcomes.include?(outcome)
142
+ raise ProtocolV3::ValidationError, 'fake effect summary is invalid' unless
143
+ summary.is_a?(String) && summary.bytesize.between?(1, 500)
144
+ if metadata && !metadata.is_a?(Hash)
145
+ raise ProtocolV3::ValidationError,
146
+ 'fake effect metadata must be an object'
147
+ end
148
+
149
+ ProtocolV3.validate_value!(definition.record_schema, ProtocolV3.stringify(payload),
150
+ label: 'fake effect payload')
151
+ end
152
+
153
+ def validate_record_size!(record)
154
+ return if JSON.generate(record).bytesize <= @configuration.limits.fetch('maxSinkRecordBytes')
155
+
156
+ raise CaptureError, 'Fake effect record exceeds the bounded record size'
157
+ end
158
+
159
+ def deep_copy(value)
160
+ JSON.parse(JSON.generate(value))
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module Rails
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end