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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Gemfile +1 -0
- data/benchmarks/documentation.rb +277 -0
- data/benchmarks/endpoints.rb +730 -0
- data/benchmarks/performance_budgets.json +1354 -0
- data/benchmarks/performance_compare.rb +579 -0
- data/doc/client-examples.md +233 -24
- data/doc/performance.md +272 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/documentation_clients.rb +158 -35
- data/lib/haveapi/documentation_context.rb +42 -12
- data/lib/haveapi/documentation_schema.rb +73 -0
- data/lib/haveapi/server.rb +5 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/context_performance_spec.rb +72 -0
- data/spec/client_examples/context_spec.rb +206 -0
- data/spec/client_examples/dynamic_js_spec.rb +1 -0
- data/spec/client_examples/profile_mount_spec.rb +215 -0
- data/spec/client_examples/profiles_spec.rb +202 -32
- data/spec/client_examples/structural_schema_spec.rb +48 -0
- data/spec/documentation/client_examples_spec.rb +10 -0
- data/spec/documentation/performance_spec.rb +79 -0
- data/spec/performance/comparator_spec.rb +342 -0
- data/spec/performance/endpoint_contract_spec.rb +226 -0
- data/spec/performance/endpoint_scaling_spec.rb +82 -0
- data/spec/performance/event_baseline_spec.rb +279 -0
- data/spec/performance/json_activation_spec.rb +151 -0
- data/spec/performance/release_lineage_spec.rb +431 -0
- data/spec/performance/runner_spec.rb +476 -0
- data/spec/performance/sqlite_spec.rb +194 -0
- data/spec/performance/support/live_worker.rb +44 -0
- data/spec/performance/support/samples.rb +72 -0
- data/test_support/documentation_performance_fixture.rb +106 -0
- data/test_support/performance_endpoint_fixture.rb +350 -0
- data/test_support/performance_sqlite_fixture.rb +384 -0
- 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
|