haveapi 0.30.0 → 0.30.2

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/documentation.rb +277 -0
  5. data/benchmarks/endpoints.rb +730 -0
  6. data/benchmarks/performance_budgets.json +1354 -0
  7. data/benchmarks/performance_compare.rb +579 -0
  8. data/doc/client-examples.md +233 -24
  9. data/doc/performance.md +272 -0
  10. data/haveapi.gemspec +1 -1
  11. data/lib/haveapi/documentation_clients.rb +158 -35
  12. data/lib/haveapi/documentation_context.rb +42 -12
  13. data/lib/haveapi/documentation_schema.rb +73 -0
  14. data/lib/haveapi/server.rb +5 -0
  15. data/lib/haveapi/version.rb +1 -1
  16. data/lib/haveapi/views/version_page.erb +2 -2
  17. data/spec/client_examples/context_performance_spec.rb +72 -0
  18. data/spec/client_examples/context_spec.rb +206 -0
  19. data/spec/client_examples/dynamic_js_spec.rb +1 -0
  20. data/spec/client_examples/profile_mount_spec.rb +215 -0
  21. data/spec/client_examples/profiles_spec.rb +202 -32
  22. data/spec/client_examples/structural_schema_spec.rb +48 -0
  23. data/spec/documentation/client_examples_spec.rb +10 -0
  24. data/spec/documentation/performance_spec.rb +79 -0
  25. data/spec/performance/comparator_spec.rb +342 -0
  26. data/spec/performance/endpoint_contract_spec.rb +226 -0
  27. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  28. data/spec/performance/event_baseline_spec.rb +279 -0
  29. data/spec/performance/json_activation_spec.rb +151 -0
  30. data/spec/performance/release_lineage_spec.rb +431 -0
  31. data/spec/performance/runner_spec.rb +476 -0
  32. data/spec/performance/sqlite_spec.rb +194 -0
  33. data/spec/performance/support/live_worker.rb +44 -0
  34. data/spec/performance/support/samples.rb +72 -0
  35. data/test_support/documentation_performance_fixture.rb +106 -0
  36. data/test_support/performance_endpoint_fixture.rb +350 -0
  37. data/test_support/performance_sqlite_fixture.rb +384 -0
  38. metadata +26 -3
@@ -0,0 +1,342 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+ require 'open3'
4
+ require_relative 'support/samples'
5
+
6
+ describe HaveAPI::Performance::Comparator do
7
+ def compare_cli(samples, budgets)
8
+ Dir.mktmpdir('haveapi-comparator-') do |directory|
9
+ input = File.join(directory, 'samples.json')
10
+ policy = File.join(directory, 'budgets.json')
11
+ output = File.join(directory, 'result')
12
+ File.write(input, JSON.generate(samples))
13
+ File.write(policy, budgets)
14
+ _stdout, _stderr, status = Open3.capture3(RbConfig.ruby, File.expand_path('../../benchmarks/endpoints.rb', __dir__),
15
+ 'compare', '--input', input, '--budgets', policy, '--output', output)
16
+ [status.exitstatus, JSON.parse(File.read(File.join(output, 'report.json')))]
17
+ end
18
+ end
19
+
20
+ def replace_raw_body(sample, body)
21
+ output = sample['outputs'].first
22
+ raw = sample['bodies'].delete(output['body_sha256'])
23
+ digest = Digest::SHA256.hexdigest(body)
24
+ sample['bodies'][digest] = raw.merge('body' => body)
25
+ output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize)
26
+ end
27
+
28
+ def rebind_source(samples, role, sha)
29
+ samples["#{role}_sha"] = sha
30
+ samples['sources'][role]['sha'] = sha
31
+ samples['observations'].each_value do |observation|
32
+ observation['source']['sha'] = sha if observation['source']['root'] == samples['sources'][role]['root']
33
+ end
34
+ end
35
+
36
+ it 'passes controlled AA traces through the actual CLI' do
37
+ samples, budgets = PerformanceSamples.fixture
38
+ code, report = compare_cli(samples, budgets)
39
+ expect(code).to eq(0)
40
+ expect(report['status']).to eq('pass')
41
+ expect(report['cases'].first['timing'].keys).to eq(%w[wall_seconds cpu_seconds])
42
+ expect(report['schema_version']).to eq(2)
43
+ expect(report['cases'].first['correctness']).to include('status' => 'pass', 'operations_per_sample' => 1)
44
+ expect(JSON.generate(report)).not_to include('canonical_body', 'body_sha256', 'self_pairs', 'observations', 'loaded_sources')
45
+ end
46
+
47
+ it 'rejects unsupported sample formats without converting historical evidence' do
48
+ samples, budgets = PerformanceSamples.fixture
49
+ samples['schema_version'] = 1
50
+ code, report = compare_cli(samples, budgets)
51
+ expect(code).to eq(3)
52
+ expect(report.dig('error', 'message')).to include('Unsupported samples schema; expected 2')
53
+ end
54
+
55
+ it 'blocks an agreed meaningful slowdown for either clock independently' do
56
+ %w[wall_seconds cpu_seconds].each do |metric|
57
+ samples, budgets = PerformanceSamples.fixture
58
+ samples['cases'].first['pairs'].each { |pair| pair['candidate'][metric] = 3.0 }
59
+ code, report = compare_cli(samples, budgets)
60
+ expect(code).to eq(1)
61
+ expect(report['cases'].first['timing'][metric]['regression_agreement']).to eq(5)
62
+ end
63
+ end
64
+
65
+ it 'retains noisy inconclusive diagnostics without failing readiness' do
66
+ samples, budgets = PerformanceSamples.fixture
67
+ samples['cases'].first['self_pairs'].first['candidate']['wall_seconds'] = 6.0
68
+ code, report = compare_cli(samples, budgets)
69
+ expect(code).to eq(0)
70
+ expect(report['status']).to eq('inconclusive')
71
+ end
72
+
73
+ it 'requires four of five quick agreements and six of seven weekly agreements' do
74
+ %w[quick weekly].each do |profile|
75
+ samples, budgets = PerformanceSamples.fixture(profile:)
76
+ agreement = HaveAPI::Performance::PROFILES.fetch(profile).fetch(:agreement)
77
+ samples['cases'].first['pairs'].first(agreement - 1).each { |pair| pair['candidate']['wall_seconds'] = 3.0 }
78
+ code, report = compare_cli(samples, budgets)
79
+ expect(code).to eq(0)
80
+ expect(report['status']).to eq('inconclusive')
81
+ samples['cases'].first['pairs'][agreement - 1]['candidate']['wall_seconds'] = 3.0
82
+ expect(compare_cli(samples, budgets).first).to eq(1)
83
+ end
84
+ end
85
+
86
+ it 'uses the stricter weekly budget on the same fifteen percent increase' do
87
+ %w[quick weekly].each do |profile|
88
+ samples, budgets = PerformanceSamples.fixture(profile:)
89
+ samples['cases'].first['pairs'].each { |pair| pair['candidate']['wall_seconds'] = 2.30 }
90
+ expect(compare_cli(samples, budgets).first).to eq(profile == 'quick' ? 0 : 1)
91
+ end
92
+ end
93
+
94
+ it 'detects a cheap-request twenty-five percent regression using the batch floor' do
95
+ samples, budgets = PerformanceSamples.fixture
96
+ item = samples['cases'].first
97
+ item['iterations'] = 25_000
98
+ (item['self_pairs'] + item['pairs']).each do |pair|
99
+ pair.each_value do |sample|
100
+ next unless sample.is_a?(Hash)
101
+
102
+ sample['operations'] = 25_000
103
+ sample['outputs'].first['count'] = 25_000
104
+ sample['counters']['operations'] = 25_000
105
+ sample['wall_seconds'] = sample['cpu_seconds'] = 0.00004
106
+ end
107
+ end
108
+ item['pairs'].each { |pair| pair['candidate']['wall_seconds'] = 0.00005 }
109
+ expect(compare_cli(samples, budgets).first).to eq(1)
110
+ end
111
+
112
+ it 'validates distinct examples and rejects malformed compact counts, identities and counter summaries' do
113
+ samples, json = PerformanceSamples.fixture
114
+ budget = JSON.parse(json)
115
+ spec = samples['cases'].first
116
+ sample = Marshal.load(Marshal.dump(spec['pairs'].first['candidate']))
117
+ sample['operations'] = 4
118
+ sample['outputs'].first['count'] = 4
119
+ sample['counters']['operations'] = 4
120
+ comparator = described_class.new(samples, budget, budget_sha256: samples['budget_sha256'])
121
+ body = sample['bodies'].values.first['body']
122
+ allow(HaveAPI::Performance::Identity).to receive(:parse).and_call_original
123
+ comparator.send(:validate_sample, sample, 4, budget['profiles']['quick'].first, spec)
124
+ expect(HaveAPI::Performance::Identity).to have_received(:parse).with(body).once
125
+ %w[http_status envelope_status body_bytes body_sha256 canonical_sha256 comparison counter missing zero negative float total counter_count duplicate unreferenced].each do |fault|
126
+ broken = Marshal.load(Marshal.dump(sample))
127
+ output = broken['outputs'].last
128
+ case fault
129
+ when 'http_status' then output[fault] = 403
130
+ when 'envelope_status' then output[fault] = false
131
+ when 'body_bytes' then output[fault] += 1
132
+ when 'comparison' then output[fault] = { 'kind' => 'own-webui-href', 'own_version' => '0.30.2' }
133
+ when 'counter' then broken['counters']['record']['authorizations'] = 0
134
+ when 'missing' then broken['counters'].delete('record')
135
+ when 'zero' then output['count'] = 0
136
+ when 'negative' then output['count'] = -1
137
+ when 'float' then output['count'] = 4.0
138
+ when 'total' then output['count'] = 3
139
+ when 'counter_count' then broken['counters']['operations'] = 4.0
140
+ when 'duplicate'
141
+ output['count'] = 2
142
+ broken['outputs'] << output.dup
143
+ when 'unreferenced'
144
+ other_body = '{"status":true}'
145
+ broken['bodies'][Digest::SHA256.hexdigest(other_body)] = { 'body' => other_body, 'canonical_body' => JSON.parse(other_body) }
146
+ else output[fault] = '0' * 64
147
+ end
148
+ expect { comparator.send(:validate_sample, broken, 4, budget['profiles']['quick'].first, spec) }.to raise_error(StandardError)
149
+ end
150
+ # A second sample must independently prove its retained body again.
151
+ comparator.send(:validate_sample, sample, 4, budget['profiles']['quick'].first, spec)
152
+ expect(HaveAPI::Performance::Identity).to have_received(:parse).with(body).at_least(:twice)
153
+ end
154
+
155
+ it 'rejects raw/canonical disagreement and duplicate JSON on a reused body and rejects counts above the shared cap' do
156
+ %w[canonical duplicate cap].each do |fault|
157
+ samples, json = PerformanceSamples.fixture
158
+ spec = samples['cases'].first
159
+ sample = spec['pairs'].first['candidate']
160
+ if fault == 'canonical'
161
+ sample['bodies'].values.first['canonical_body']['response']['zero'] = 1
162
+ elsif fault == 'duplicate'
163
+ body = sample['bodies'].values.first['body'].sub('"status":true', '"status":false,"status":true')
164
+ replace_raw_body(sample, body)
165
+ else
166
+ spec['iterations'] = 100_001
167
+ end
168
+ comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
169
+ expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError)
170
+ end
171
+ end
172
+
173
+ it 'compares response occurrence totals across revisions without retaining request order' do
174
+ samples, json = PerformanceSamples.fixture
175
+ spec = samples['cases'].first
176
+ spec['iterations'] = 3
177
+ (spec['self_pairs'] + spec['pairs']).each do |pair|
178
+ %w[baseline candidate].each do |role|
179
+ sample = pair[role]
180
+ output = sample['outputs'].first
181
+ body = sample['bodies'].values.first['body'].sub('"zero":0', '"zero":1')
182
+ canonical = JSON.parse(body)
183
+ digest = Digest::SHA256.hexdigest(body)
184
+ sample['bodies'][digest] = { 'body' => body, 'canonical_body' => canonical }
185
+ sample['operations'] = 3
186
+ sample['outputs'] << output.merge('body_sha256' => digest, 'body_bytes' => body.bytesize,
187
+ 'canonical_sha256' => HaveAPI::Performance::Identity.digest(canonical), 'count' => 2)
188
+ sample['counters']['operations'] = 3
189
+ end
190
+ end
191
+ comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
192
+ expect(comparator.compare['status']).to eq('pass')
193
+ spec['pairs'].last['candidate']['outputs'].reverse!
194
+ expect(comparator.compare['status']).to eq('pass')
195
+ spec['pairs'].last['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] }
196
+ expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError, /Response identity/)
197
+ end
198
+
199
+ it 'rejects retained runaway request batches even if both sources are equally slow' do
200
+ samples, budgets = PerformanceSamples.fixture
201
+ item = samples['cases'].first
202
+ (item['self_pairs'] + item['pairs']).each do |pair|
203
+ %w[baseline candidate].each { |role| pair[role]['wall_seconds'] = 100.0 }
204
+ end
205
+ code, report = compare_cli(samples, budgets)
206
+ expect(code).to eq(3)
207
+ expect(report.dig('error', 'message')).to include('Request batch exceeds 30.0s')
208
+ end
209
+
210
+ it 'rejects raw integer/float drift even when forged raw metadata is consistent' do
211
+ [[0, 0.0], [0.0, 0]].each do |original, changed|
212
+ samples, budgets = PerformanceSamples.fixture(zero: original)
213
+ target = samples['cases'].first['pairs'].first['candidate']
214
+ body = JSON.parse(target['bodies'].values.first['body'])
215
+ body['response']['zero'] = changed
216
+ replace_raw_body(target, JSON.generate(body))
217
+ code, report = compare_cli(samples, budgets)
218
+ expect(code).to eq(3)
219
+ expect(report.dig('error', 'message')).to include('Raw/canonical output values differ')
220
+ end
221
+ end
222
+
223
+ it 'allows raw map-order changes through the actual CLI' do
224
+ samples, budgets = PerformanceSamples.fixture
225
+ target = samples['cases'].first['pairs'].first['candidate']
226
+ body = JSON.parse(target['bodies'].values.first['body'])
227
+ body['response'] = body['response'].to_a.reverse.to_h
228
+ replace_raw_body(target, JSON.generate(body.to_a.reverse.to_h))
229
+ expect(compare_cli(samples, budgets).first).to eq(0)
230
+ end
231
+
232
+ it 'rejects identical source SHAs even with consistently rebound provenance' do
233
+ samples, budgets = PerformanceSamples.fixture
234
+ rebind_source(samples, 'candidate', samples['baseline_sha'])
235
+ code, report = compare_cli(samples, budgets)
236
+ expect(code).to eq(3)
237
+ expect(report.dig('error', 'message')).to include('Baseline and candidate must differ')
238
+ end
239
+
240
+ it 'requires the fixed weekly baseline and accepts valid fixed-reference evidence' do
241
+ samples, budgets = PerformanceSamples.fixture(profile: 'weekly')
242
+ expect(compare_cli(samples, budgets).first).to eq(0)
243
+ rebind_source(samples, 'baseline', 'a' * 40)
244
+ code, report = compare_cli(samples, budgets)
245
+ expect(code).to eq(3)
246
+ expect(report.dig('error', 'message')).to include('Weekly baseline must be the fixed reference')
247
+ end
248
+
249
+ it 'requires both the percentage and absolute timing thresholds' do
250
+ samples, budgets = PerformanceSamples.fixture
251
+ samples['cases'].first['pairs'].each do |pair|
252
+ pair['baseline']['wall_seconds'] = 0.0001
253
+ pair['candidate']['wall_seconds'] = 0.00013
254
+ end
255
+ expect(compare_cli(samples, budgets).first).to eq(0)
256
+ end
257
+
258
+ it 'blocks allocation growth and the independent fixed reference ceiling' do
259
+ samples, budgets = PerformanceSamples.fixture
260
+ samples['cases'].first['pairs'].each { |pair| pair['candidate']['allocated_objects'] = 140 }
261
+ expect(compare_cli(samples, budgets).first).to eq(1)
262
+ samples['cases'].first['pairs'].each do |pair|
263
+ pair['baseline']['allocated_objects'] = 180
264
+ pair['candidate']['allocated_objects'] = 180
265
+ end
266
+ expect(compare_cli(samples, budgets).first).to eq(1)
267
+ end
268
+
269
+ it 'requires full Rust cases and their relative allocation and authorization contracts' do
270
+ %w[missing allocations authorization].each do |fault|
271
+ samples, budgets = PerformanceSamples.fixture
272
+ case fault
273
+ when 'missing'
274
+ samples['inventory'].pop
275
+ samples['cases'].pop
276
+ when 'allocations'
277
+ samples['cases'].last['pairs'].each { |pair| pair['candidate']['allocated_objects'] = 200 }
278
+ else
279
+ samples['cases'].last['pairs'].first['candidate']['counters']['record']['authorizations'] -= 1
280
+ end
281
+ expect(compare_cli(samples, budgets).first).to eq(fault == 'allocations' ? 1 : 3)
282
+ end
283
+ end
284
+
285
+ it 'blocks uncalibrated reference budgets' do
286
+ samples, json = PerformanceSamples.fixture
287
+ budgets = JSON.parse(json)
288
+ budgets.delete('calibration')
289
+ json = JSON.generate(budgets)
290
+ samples['budget_sha256'] = Digest::SHA256.hexdigest(json)
291
+ expect(compare_cli(samples, json).first).to eq(3)
292
+ end
293
+
294
+ it 'blocks missing cases, operations, dependency graphs and source provenance' do
295
+ %w[cases outputs inputs sources].each do |missing|
296
+ samples, budgets = PerformanceSamples.fixture
297
+ case missing
298
+ when 'cases' then samples['cases'].clear
299
+ when 'outputs' then samples['cases'].first['pairs'].first['candidate']['outputs'].clear
300
+ else samples.delete(missing)
301
+ end
302
+ expect(compare_cli(samples, budgets).first).to eq(3)
303
+ end
304
+ end
305
+
306
+ it 'rejects missing or changed workload order and runtime policy' do
307
+ %w[workload_order worker_order runtime_policy].each do |fault|
308
+ samples, budgets = PerformanceSamples.fixture
309
+ case fault
310
+ when 'workload_order' then samples['cases'].first['pairs'][1]['workload_order'] = 'forward'
311
+ when 'worker_order' then samples['observations']['candidate-reverse']['workload_order'] = 'forward'
312
+ else samples['observations']['baseline']['runtime_policy']['timezone'] = 'local'
313
+ end
314
+ expect(compare_cli(samples, budgets).first).to eq(3)
315
+ end
316
+ end
317
+
318
+ it 'blocks extra queries, lost authorizations, wrong output and changed dependency graphs' do
319
+ %w[queries authorizations output environment].each do |fault|
320
+ samples, budgets = PerformanceSamples.fixture
321
+ target = samples['cases'].first['pairs'].first['candidate']
322
+ case fault
323
+ when 'queries' then target['counters']['record']['queries'] = 1
324
+ when 'authorizations' then target['counters']['record']['authorizations'] = 0
325
+ when 'output' then target['outputs'].first['canonical_sha256'] = '0' * 64
326
+ else samples['observations']['candidate']['environment'] = {}
327
+ end
328
+ expect(compare_cli(samples, budgets).first).to eq(3)
329
+ end
330
+ end
331
+
332
+ it 'preserves scalar types and array order while ignoring map order only' do
333
+ identity = HaveAPI::Performance::Identity
334
+ expect(identity.digest(a: 0, b: [false, '', nil])).to eq(identity.digest('b' => [false, '', nil], 'a' => 0))
335
+ expect(identity.digest(a: 0)).not_to eq(identity.digest(a: 0.0))
336
+ expect(identity.digest(a: [false, nil])).not_to eq(identity.digest(a: [nil, false]))
337
+ expect { identity.digest(a: 0, 'a' => 1) }.to raise_error(HaveAPI::Performance::ContractError)
338
+ ['{"status":false,"status":true}', '{"a":[{"b":1,"b":2}]}'].each do |json|
339
+ expect { identity.parse(json) }.to raise_error(HaveAPI::Performance::ContractError, /Duplicate/)
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,226 @@
1
+ require 'spec_helper'
2
+ require_relative '../../test_support/performance_endpoint_fixture'
3
+
4
+ RSpec.describe HaveAPI::PerformanceFixtures::Endpoints do
5
+ let(:scenarios) { [] }
6
+
7
+ def build(id = 'api.basic_get', **dimensions)
8
+ scenario = described_class.build(id:, dimensions:)
9
+ scenarios << scenario
10
+ scenario
11
+ end
12
+
13
+ def headers(user = 'member')
14
+ { 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT' => 'application/json',
15
+ 'HTTP_AUTHORIZATION' => "Basic #{["#{user}:#{described_class::PASSWORD}"].pack('m0')}",
16
+ 'CONTENT_TYPE' => 'application/json' }
17
+ end
18
+
19
+ def json_request(request, method, path, body = nil, user: 'member')
20
+ response = request.request(method, "/performance/v1/#{path}",
21
+ headers(user).merge(input: body.nil? ? '' : JSON.generate(body)))
22
+ [response, JSON.parse(response.body)]
23
+ end
24
+
25
+ after do
26
+ scenarios.each(&:close)
27
+ end
28
+
29
+ it 'retains the seven quick cases and adds only the accepted weekly shapes' do
30
+ quick = described_class.cases
31
+ expect(quick.map { |item| item[:id] }).to eq(described_class::CASE_IDS)
32
+ expect(quick.last).to eq(id: 'api.list', dimensions: { rows: 32 })
33
+ expect(quick[0...-1].map { |item| item[:dimensions] }).to all(eq({}))
34
+ weekly = described_class.cases(profile: 'weekly')
35
+ expect(weekly.first(quick.length)).to eq(quick)
36
+ additions = [
37
+ { id: 'api.list', dimensions: { rows: 1024, user: 'member' } },
38
+ { id: 'api.list', dimensions: { rows: 1024, user: 'admin' } },
39
+ { id: 'api.basic_get', dimensions: { user: 'admin' } }
40
+ ]
41
+ expect(weekly.drop(quick.length)).to eq(additions)
42
+ expect(weekly.uniq).to eq(weekly)
43
+ end
44
+
45
+ it 'executes and validates every real quick-case response and counter record' do
46
+ described_class.cases.each do |item|
47
+ scenario = build(item[:id], **item[:dimensions])
48
+ scenario.reset!(iterations: 3)
49
+ responses = Array.new(3) { scenario.call }
50
+ responses.each_with_index do |response, index|
51
+ identity = scenario.validate!(response, index:)
52
+ expect(identity).to include(http_status: 200, body_bytes: response.body.bytesize,
53
+ body_sha256: Digest::SHA256.hexdigest(response.body))
54
+ expect(identity[:envelope_status]).to eq(item[:id] != 'api.invalid_post')
55
+ expect(identity[:canonical_body]).to eq(JSON.parse(response.body))
56
+ expect(identity[:canonical_sha256]).to eq(HaveAPI::Performance::Identity.digest(JSON.parse(response.body)))
57
+ end
58
+ executions = item[:id] == 'api.invalid_post' ? 0 : 1
59
+ expect(scenario.counters).to eq(operations: Array.new(3) { { queries: 0, authorizations: 1, executions: } })
60
+ end
61
+ end
62
+
63
+ it 'validates the weekly large-list and administrator shapes' do
64
+ described_class.cases(profile: 'weekly').drop(described_class::CASE_IDS.length).each do |item|
65
+ scenario = build(item[:id], **item[:dimensions])
66
+ scenario.reset!(iterations: 1)
67
+ identity = scenario.validate!(scenario.call, index: 0)
68
+ output = identity.fetch(:canonical_body).fetch('response').fetch('probe')
69
+ records = item[:id] == 'api.list' ? output : [output]
70
+ expect(records.length).to eq(item[:dimensions].fetch(:rows, 1))
71
+ records.each do |record|
72
+ expect(record.has_key?('secret')).to eq(item[:dimensions][:user] == 'admin')
73
+ end
74
+ expect(scenario.counters).to eq(operations: [{ queries: 0, authorizations: 1, executions: 1 }])
75
+ end
76
+ end
77
+
78
+ it 'rejects a corrupted early response even when the final response is valid' do
79
+ scenario = build
80
+ scenario.reset!(iterations: 2)
81
+ first = scenario.call
82
+ last = scenario.call
83
+ body = JSON.parse(first.body)
84
+ body['response']['probe']['count'] = '0'
85
+ broken = Rack::MockResponse.new(200, first.headers, JSON.generate(body))
86
+
87
+ expect { scenario.validate!(last, index: 1) }.not_to raise_error
88
+ expect { scenario.validate!(broken, index: 0) }.to raise_error(/Unexpected output/)
89
+ expect { scenario.validate!(Rack::MockResponse.new(403, {}, '{}'), index: 0) }
90
+ .to raise_error(/HTTP status/)
91
+ duplicate = Rack::MockResponse.new(200, first.headers, first.body.sub('"status":true', '"status":false,"status":true'))
92
+ expect { scenario.validate!(duplicate, index: 0) }.to raise_error(HaveAPI::Performance::ContractError, /duplicate/i)
93
+ end
94
+
95
+ it 'preserves typed zero, false, empty, null, nested Custom data and omission' do
96
+ scenario = build('api.typed_post')
97
+ scenario.reset!(iterations: 1)
98
+ response = scenario.call
99
+ scenario.validate!(response, index: 0)
100
+ output = JSON.parse(response.body).fetch('response').fetch('probe')
101
+
102
+ expect(output).to include('count' => 0, 'ratio' => 1.25, 'enabled' => false, 'label' => '', 'nullable' => nil,
103
+ 'at' => '2020-01-01T00:00:00+00:00', 'payload' => { 'nested' => [0, false, '', nil] })
104
+ expect(output).not_to have_key('omitted')
105
+ end
106
+
107
+ it 'uses distinct Basic and token providers with real credential failures' do
108
+ basic = build('api.basic_get')
109
+ token = build('api.token_get')
110
+ [basic, token].each { |scenario| scenario.reset!(iterations: 1) }
111
+ expect(basic.validate!(basic.call, index: 0)).to eq(token.validate!(token.call, index: 0))
112
+ request = Rack::MockRequest.new(basic.app)
113
+ missing = request.get('/performance/v1/probes/secure', 'HTTP_HOST' => 'example.test')
114
+ bad_basic = request.get('/performance/v1/probes/secure', headers.merge('HTTP_AUTHORIZATION' => 'Basic invalid'))
115
+ bad_token = request.get('/performance/v1/probes/secure', headers.merge('HTTP_AUTHORIZATION' => '',
116
+ 'HTTP_X_HAVEAPI_AUTH_TOKEN' => 'invalid'))
117
+ expect([missing.status, bad_basic.status, bad_token.status]).to eq([401, 401, 401])
118
+ [missing, bad_basic, bad_token].each { |response| expect(JSON.parse(response.body)['status']).to be(false) }
119
+ denied, body = json_request(request, 'GET', 'probes/admin')
120
+ expect(denied.status).to eq(403)
121
+ expect(body.fetch('status')).to be(false)
122
+ allowed, body = json_request(request, 'GET', 'probes/admin', user: 'admin')
123
+ expect(allowed.status).to eq(200)
124
+ expect(body.dig('response', 'probe', 'secret')).to eq('ADMIN_ONLY_VALUE')
125
+ end
126
+
127
+ it 'executes real CRUD, isolates stores and resets writes outside measured requests' do
128
+ scenario = build('api.list', rows: 1)
129
+ other = build('api.list', rows: 1)
130
+ scenario.reset!(iterations: 1)
131
+ other.reset!(iterations: 1)
132
+ request = Rack::MockRequest.new(scenario.app)
133
+ created, body = json_request(request, 'POST', 'items', { probe: { label: 'created' } })
134
+ expect(created.status).to eq(200)
135
+ expect(body.fetch('status')).to be(true)
136
+ expect(body.dig('response', 'probe')).to include('id' => 2, 'label' => 'created')
137
+ _, body = json_request(request, 'GET', 'items/2')
138
+ expect(body.dig('response', 'probe', 'label')).to eq('created')
139
+ _, body = json_request(request, 'PUT', 'items/2', { probe: { label: 'updated' } })
140
+ expect(body.dig('response', 'probe', 'label')).to eq('updated')
141
+ _, body = json_request(request, 'DELETE', 'items/2')
142
+ expect(body.dig('response', 'probe', 'label')).to eq('updated')
143
+ missing, body = json_request(request, 'GET', 'items/2')
144
+ expect(missing.status).to eq(404)
145
+ expect(body.fetch('status')).to be(false)
146
+ json_request(request, 'POST', 'items', { probe: { label: 'discarded by reset' } })
147
+ expect(other.validate!(other.call, index: 0)[:envelope_status]).to be(true)
148
+ scenario.reset!(iterations: 1)
149
+ expect(scenario.validate!(scenario.call, index: 0)[:envelope_status]).to be(true)
150
+ end
151
+
152
+ it 'does not mutate timed counter records through direct app requests' do
153
+ scenario = build
154
+ scenario.reset!(iterations: 1)
155
+ response = scenario.call
156
+ before = Marshal.dump(scenario.counters)
157
+ request = Rack::MockRequest.new(scenario.app)
158
+ json_request(request, 'GET', 'probes/secure')
159
+ json_request(request, 'POST', 'items', { probe: { label: 'untimed' } })
160
+
161
+ expect(Marshal.dump(scenario.counters)).to eq(before)
162
+ expect { scenario.validate!(response, index: 0) }.not_to raise_error
163
+ end
164
+
165
+ it 'rejects malformed bodies, missing routes and invalid typed input through real middleware' do
166
+ scenario = build
167
+ request = Rack::MockRequest.new(scenario.app)
168
+ ['{', '[]', 'null'].each do |input|
169
+ response = request.post('/performance/v1/probes/typed', headers.merge(input:))
170
+ expect(response.status).to eq(400)
171
+ expect(JSON.parse(response.body)['status']).to be(false)
172
+ end
173
+ response = request.get('/performance/v1/not-a-route', headers)
174
+ expect(response.status).to eq(404)
175
+ expect(JSON.parse(response.body)['status']).to be(false)
176
+ response = request.post('/performance/v1/probes/typed', headers.merge(input: '{"probe":{"count":"wrong"}}'))
177
+ expect(response.status).to eq(200)
178
+ expect(JSON.parse(response.body).fetch('errors')).to have_key('count')
179
+ end
180
+
181
+ it 'alternates privileged and restricted users without exposing hidden fields' do
182
+ scenario = build
183
+ request = Rack::MockRequest.new(scenario.app)
184
+ %w[admin member admin].each do |user|
185
+ _, body = json_request(request, 'GET', 'probes/secure', user:)
186
+ output = body.fetch('response').fetch('probe')
187
+ if user == 'admin'
188
+ expect(output.fetch('secret')).to eq('ADMIN_ONLY_VALUE')
189
+ else
190
+ expect(output).not_to have_key('secret')
191
+ expect(JSON.generate(body)).not_to include('ADMIN_ONLY_VALUE')
192
+ end
193
+ end
194
+ end
195
+
196
+ it 'requires reset and every operation record, and rejects overflow and damaged counters' do
197
+ scenario = build
198
+ expect { scenario.call }.to raise_error(/reset!/)
199
+ expect { scenario.counters }.to raise_error(/Missing operation/)
200
+ scenario.reset!(iterations: 2)
201
+ first = scenario.call
202
+ expect { scenario.counters }.to raise_error(/Missing operation/)
203
+ scenario.call
204
+ expect { scenario.call }.to raise_error(/More calls/)
205
+ expect { scenario.validate!(first, index: -1) }.to raise_error(ArgumentError)
206
+ expect { scenario.validate!(first, index: 2) }.to raise_error(/not called/)
207
+ scenario.counters[:operations].first[:queries] = 1
208
+ expect { scenario.validate!(first, index: 0) }.to raise_error(/counts/)
209
+ expect { scenario.counters }.to raise_error(/counts/)
210
+ scenario.reset!(iterations: 1)
211
+ scenario.validate!(scenario.call, index: 0)
212
+ scenario.close
213
+ expect { scenario.call }.to raise_error(/closed/)
214
+ expect { scenario.reset!(iterations: 1) }.to raise_error(/closed/)
215
+ end
216
+
217
+ it 'fails closed on unknown cases, profiles, dimensions, users and invalid batch sizes' do
218
+ expect { described_class.cases(profile: 'other') }.to raise_error(ArgumentError)
219
+ expect { build('api.unknown') }.to raise_error(ArgumentError)
220
+ expect { build('api.list', rows: 0) }.to raise_error(ArgumentError)
221
+ expect { build('api.list', rows: '32') }.to raise_error(ArgumentError)
222
+ expect { build('api.list', user: 'unknown') }.to raise_error(ArgumentError)
223
+ expect { build('api.list', accidental: true) }.to raise_error(ArgumentError)
224
+ expect { build.reset!(iterations: 0) }.to raise_error(ArgumentError)
225
+ end
226
+ end
@@ -0,0 +1,82 @@
1
+ require 'spec_helper'
2
+ require_relative '../../test_support/performance_endpoint_fixture'
3
+
4
+ RSpec.describe HaveAPI::PerformanceFixtures::Endpoints, '.build' do
5
+ def run_list(rows)
6
+ scenario = described_class.build(id: 'api.list', dimensions: { rows: })
7
+ scenario.reset!(iterations: 2)
8
+ responses = Array.new(2) { scenario.call }
9
+ identities = responses.each_with_index.map { |response, index| scenario.validate!(response, index:) }
10
+ [identities, scenario.counters]
11
+ ensure
12
+ scenario&.close
13
+ end
14
+
15
+ def allocated_per_request(rows)
16
+ scenario = described_class.build(id: 'api.list', dimensions: { rows: })
17
+ 2.times do
18
+ scenario.reset!(iterations: 10)
19
+ 10.times { scenario.call }
20
+ end
21
+ Array.new(3) do
22
+ scenario.reset!(iterations: 10)
23
+ responses = Array.new(10)
24
+ GC.start
25
+ allocated = GC.stat(:total_allocated_objects)
26
+ index = 0
27
+ while index < responses.length
28
+ responses[index] = scenario.call
29
+ index += 1
30
+ end
31
+ delta = GC.stat(:total_allocated_objects) - allocated
32
+ responses.each_with_index { |response, operation| scenario.validate!(response, index: operation) }
33
+ scenario.counters
34
+ delta / 10.0
35
+ end.min
36
+ ensure
37
+ scenario&.close
38
+ end
39
+
40
+ it 'serializes complete ordered lists at sizes 1, 32 and 128 with fixed action work' do
41
+ bytes = [1, 32, 128].map do |rows|
42
+ identities, counters = run_list(rows)
43
+ expect(identities.first).to eq(identities.last)
44
+ output = identities.first[:canonical_body].fetch('response').fetch('probe')
45
+ expect(output.length).to eq(rows)
46
+ expect(output.map { |row| row.fetch('id') }).to eq((1..rows).to_a)
47
+ expect(output.last.fetch('label')).to eq("item-#{rows}")
48
+ output.each { |row| expect(row).not_to have_key('secret') }
49
+ expect(counters[:operations]).to eq(Array.new(2) { { queries: 0, authorizations: 1, executions: 1 } })
50
+ identities.first[:body_bytes]
51
+ end
52
+ expect(bytes.each_cons(2)).to all(satisfy { |small, large| small < large })
53
+ end
54
+
55
+ it 'keeps output and counters independent across reset batch sizes' do
56
+ scenario = described_class.build(id: 'api.list', dimensions: { rows: 32 })
57
+ scenario.reset!(iterations: 1)
58
+ first = scenario.validate!(scenario.call, index: 0)
59
+ records = scenario.counters[:operations]
60
+ scenario.reset!(iterations: 3)
61
+ responses = Array.new(3) { scenario.call }
62
+ responses.each_with_index do |response, index|
63
+ expect(scenario.validate!(response, index:)).to eq(first)
64
+ end
65
+ expect(scenario.counters[:operations].length).to eq(3)
66
+ expect(scenario.counters[:operations].first).not_to equal(records.first)
67
+ expect(scenario.counters[:operations].map(&:object_id).uniq.length).to eq(3)
68
+ ensure
69
+ scenario&.close
70
+ end
71
+
72
+ it 'bounds allocation growth by serialized rows rather than their square' do
73
+ small, medium, large = [1, 32, 128].map { |rows| allocated_per_request(rows) }
74
+
75
+ # Reference Ruby 3.4.11: 640/795/1275 objects per request. Output work
76
+ # grows with rows; the 96-row increment has a 4x allowance relative to
77
+ # the 31-row increment, plus 32 objects of fixed VM bookkeeping slack.
78
+ expect(medium).to be >= small
79
+ expect(large).to be > medium
80
+ expect(large - medium).to be <= (4 * (medium - small)) + 32
81
+ end
82
+ end